diff --git a/vendor/bytelyst/accessibility/package.json b/vendor/bytelyst/accessibility/package.json new file mode 100644 index 0000000..365bed6 --- /dev/null +++ b/vendor/bytelyst/accessibility/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bytelyst/accessibility", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/accessibility/src/index.ts b/vendor/bytelyst/accessibility/src/index.ts new file mode 100644 index 0000000..1d760ab --- /dev/null +++ b/vendor/bytelyst/accessibility/src/index.ts @@ -0,0 +1,204 @@ +export type AlertA11yProps = { + role: 'alert'; + 'aria-live': 'assertive' | 'polite'; + 'aria-label': string; +}; + +export function alertLabel(level: string, description: string): AlertA11yProps { + return { + role: 'alert', + 'aria-live': level === 'danger' ? 'assertive' : 'polite', + 'aria-label': description, + }; +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + element => + !element.hasAttribute('disabled') && + element.getAttribute('aria-hidden') !== 'true' && + element.offsetParent !== null + ); +} + +export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void { + if (event.key !== 'Tab') return; + + const focusable = getFocusableElements(container); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void { + const preferred = container.querySelector(selector); + const fallback = getFocusableElements(container)[0]; + (preferred ?? fallback)?.focus(); +} + +export type ScreenReaderPoliteness = 'assertive' | 'polite'; + +export function announceToScreenReader( + message: string, + politeness: ScreenReaderPoliteness = 'polite' +): void { + if (typeof document === 'undefined') return; + + const id = `bytelyst-sr-announcer-${politeness}`; + let announcer = document.getElementById(id); + if (!announcer) { + announcer = document.createElement('div'); + announcer.id = id; + announcer.setAttribute('aria-live', politeness); + announcer.setAttribute('aria-atomic', 'true'); + announcer.style.position = 'absolute'; + announcer.style.width = '1px'; + announcer.style.height = '1px'; + announcer.style.margin = '-1px'; + announcer.style.padding = '0'; + announcer.style.overflow = 'hidden'; + announcer.style.clip = 'rect(0 0 0 0)'; + announcer.style.whiteSpace = 'nowrap'; + announcer.style.border = '0'; + document.body.appendChild(announcer); + } + + announcer.textContent = ''; + window.setTimeout(() => { + if (announcer) { + announcer.textContent = message; + } + }, 10); +} + +export type ProgressbarA11yProps = { + role: 'progressbar'; + 'aria-label': string; + 'aria-valuenow': number; + 'aria-valuemin': number; + 'aria-valuemax': number; + 'aria-valuetext': string; +}; + +export function progressLabel( + label: string, + valuePct: number, + description: string +): ProgressbarA11yProps { + return { + role: 'progressbar', + 'aria-label': label, + 'aria-valuenow': valuePct, + 'aria-valuemin': 0, + 'aria-valuemax': 100, + 'aria-valuetext': description, + }; +} + +export type AriaLabelOnly = { 'aria-label': string }; + +export function streakLabel(days: number): AriaLabelOnly { + return { 'aria-label': `${days} day streak` }; +} + +export type ButtonA11yProps = { + 'aria-label': string; + 'aria-roledescription'?: string; +}; + +export function buttonLabel(text: string, hint?: string): ButtonA11yProps { + return { + 'aria-label': text, + ...(hint ? { 'aria-roledescription': hint } : {}), + }; +} + +export function achievementLabel(name: string, description: string): AriaLabelOnly { + return { 'aria-label': `Achievement: ${name} — ${description}` }; +} + +export type TimerA11yProps = { + 'aria-label': string; + 'aria-live': 'polite'; +}; + +export function timerLabel( + hours: number, + minutes: number, + seconds: number, + status: string +): TimerA11yProps { + return { + 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, + 'aria-live': 'polite', + }; +} + +export type SliderA11yProps = { + role: 'slider'; + 'aria-label': string; + 'aria-valuenow': number; + 'aria-valuemin': number; + 'aria-valuemax': number; +}; + +export function sliderLabel( + label: string, + value: number, + min: number, + max: number +): SliderA11yProps { + return { + role: 'slider', + 'aria-label': label, + 'aria-valuenow': value, + 'aria-valuemin': min, + 'aria-valuemax': max, + }; +} + +function plural(n: number, singular: string, pluralForm: string): string { + const word = n === 1 ? singular : pluralForm; + return `${n} ${word}`; +} + +/** + * Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes". + */ +export function formatDurationForA11y(hours: number): string { + const totalMinutes = Math.round(hours * 60); + const h = Math.floor(totalMinutes / 60); + const m = totalMinutes % 60; + + if (h === 0 && m === 0) { + return '0 minutes'; + } + if (m === 0) { + return plural(h, 'hour', 'hours'); + } + if (h === 0) { + return plural(m, 'minute', 'minutes'); + } + return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`; +} diff --git a/vendor/bytelyst/accessibility/tsconfig.json b/vendor/bytelyst/accessibility/tsconfig.json new file mode 100644 index 0000000..5626e4b --- /dev/null +++ b/vendor/bytelyst/accessibility/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/vendor/bytelyst/auth-client/package.json b/vendor/bytelyst/auth-client/package.json new file mode 100644 index 0000000..bc36103 --- /dev/null +++ b/vendor/bytelyst/auth-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/auth-client", + "version": "0.1.5", + "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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts b/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts new file mode 100644 index 0000000..ac5292a --- /dev/null +++ b/vendor/bytelyst/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/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts b/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts new file mode 100644 index 0000000..e7c5f17 --- /dev/null +++ b/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts @@ -0,0 +1,571 @@ +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 — SmartAuth', () => { + let storage: ReturnType; + + beforeEach(() => { + storage = createMockStorage(); + vi.restoreAllMocks(); + }); + + // ── Phase 1C: Google Sign-In ────────────────────── + + describe('loginWithGoogle', () => { + it('calls POST /auth/oauth/google and stores tokens', async () => { + const mockData = { + accessToken: 'google-at', + refreshToken: 'google-rt', + user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.loginWithGoogle('google-id-token-123'); + + expect('user' in result).toBe(true); + if ('user' in result) { + expect(result.user.email).toBe('g@gmail.com'); + } + expect(client.getAccessToken()).toBe('google-at'); + expect(client.getRefreshToken()).toBe('google-rt'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/google'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.idToken).toBe('google-id-token-123'); + expect(body.productId).toBe('testapp'); + }); + + it('returns MFA challenge when mfaRequired is true', async () => { + const mfaData = { + mfaRequired: true, + mfaChallenge: 'challenge-token-abc', + methods: ['totp'], + }; + globalThis.fetch = mockFetchResponse(mfaData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.loginWithGoogle('google-id-token-456'); + + expect('mfaRequired' in result).toBe(true); + if ('mfaRequired' in result) { + expect(result.mfaRequired).toBe(true); + expect(result.mfaChallenge).toBe('challenge-token-abc'); + expect(result.methods).toEqual(['totp']); + } + // Tokens should NOT be stored when MFA is required + expect(client.isAuthenticated()).toBe(false); + }); + }); + + describe('loginWithMicrosoft', () => { + it('calls POST /auth/oauth/microsoft', async () => { + const mockData = { + accessToken: 'ms-at', + refreshToken: 'ms-rt', + user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + await client.loginWithMicrosoft('ms-id-token'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft'); + expect(client.getAccessToken()).toBe('ms-at'); + }); + }); + + describe('loginWithApple', () => { + it('calls POST /auth/oauth/apple', async () => { + const mockData = { + accessToken: 'apple-at', + refreshToken: 'apple-rt', + user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' }, + }; + globalThis.fetch = mockFetchResponse(mockData); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + await client.loginWithApple('apple-id-token'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/oauth/apple'); + expect(client.getAccessToken()).toBe('apple-at'); + }); + }); + + // ── Phase 1C: Provider management ───────────────── + + describe('getProviders', () => { + it('calls GET /auth/providers', async () => { + const providers = { + providers: [ + { + provider: 'google', + email: 'g@gmail.com', + linkedAt: '2025-01-01T00:00:00Z', + lastUsedAt: null, + }, + ], + }; + globalThis.fetch = mockFetchResponse(providers); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.getProviders(); + + expect(result).toHaveLength(1); + expect(result[0].provider).toBe('google'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers'); + expect(opts.method).toBe('GET'); + expect(opts.headers['Authorization']).toBe('Bearer tok'); + }); + }); + + describe('linkProvider', () => { + it('calls POST /auth/providers/link with provider and idToken', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.linkProvider('google', 'link-token'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers/link'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.provider).toBe('google'); + expect(body.idToken).toBe('link-token'); + }); + }); + + describe('unlinkProvider', () => { + it('calls DELETE /auth/providers/:provider', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.unlinkProvider('google'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/providers/google'); + expect(opts.method).toBe('DELETE'); + }); + }); + + // ── Phase 2D: MFA ───────────────────────────────── + + describe('verifyMfa', () => { + it('sends challenge token and TOTP code, stores tokens', async () => { + const mockData = { + accessToken: 'mfa-at', + refreshToken: 'mfa-rt', + 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.verifyMfa('challenge-xyz', '123456', 'totp'); + + expect(result.user.email).toBe('a@b.com'); + expect(client.getAccessToken()).toBe('mfa-at'); + + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.challengeToken).toBe('challenge-xyz'); + expect(body.code).toBe('123456'); + expect(body.method).toBe('totp'); + }); + }); + + describe('setupTotp', () => { + it('calls POST /auth/mfa/setup', async () => { + const setup = { + secret: 'ABCDEFGH', + otpauthUri: 'otpauth://totp/Test?secret=ABC', + recoveryCodes: ['code1', 'code2'], + }; + globalThis.fetch = mockFetchResponse(setup); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.setupTotp(); + + expect(result.otpauthUri).toContain('otpauth://'); + expect(result.secret).toBe('ABCDEFGH'); + expect(result.recoveryCodes).toHaveLength(2); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/mfa/setup'); + }); + }); + + describe('getMfaStatus', () => { + it('returns MFA status', async () => { + const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 }; + globalThis.fetch = mockFetchResponse(status); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.getMfaStatus(); + expect(result.mfaEnabled).toBe(true); + expect(result.methods).toContain('totp'); + }); + }); + + // ── Phase 3: Passkeys ───────────────────────────── + + describe('listPasskeys', () => { + it('calls GET /auth/passkeys and unwraps response', async () => { + const data = { + passkeys: [ + { + credentialId: 'pk1', + friendlyName: 'MacBook', + deviceType: 'singleDevice', + backedUp: true, + lastUsedAt: '2025-01-01T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + }, + ], + }; + globalThis.fetch = mockFetchResponse(data); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.listPasskeys(); + expect(result).toHaveLength(1); + expect(result[0].friendlyName).toBe('MacBook'); + expect(result[0].credentialId).toBe('pk1'); + }); + }); + + describe('verifyPasskeyAuth', () => { + it('stores tokens after passkey authentication', async () => { + const mockData = { + accessToken: 'pk-at', + refreshToken: 'pk-rt', + 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.verifyPasskeyAuth({ id: 'cred-1', response: {} }); + + expect(result.user.email).toBe('a@b.com'); + expect(client.getAccessToken()).toBe('pk-at'); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify'); + }); + }); + + // ── Phase 3: Devices ────────────────────────────── + + describe('listDevices', () => { + it('calls GET /auth/devices and unwraps response', async () => { + const data = { + devices: [ + { + fingerprint: 'fp-abc', + trustLevel: 'trusted', + deviceInfo: { platform: 'web' }, + lastIp: '1.2.3.4', + trustExpiresAt: '2025-04-01T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + lastSeenAt: '2025-01-01T00:00:00Z', + isTrusted: true, + }, + ], + }; + globalThis.fetch = mockFetchResponse(data); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.listDevices(); + expect(result).toHaveLength(1); + expect(result[0].trustLevel).toBe('trusted'); + expect(result[0].fingerprint).toBe('fp-abc'); + }); + }); + + describe('trustDevice', () => { + it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.trustDevice('fp-abc', 'trusted', { platform: 'web' }); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/trust'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.fingerprint).toBe('fp-abc'); + expect(body.trustLevel).toBe('trusted'); + expect(body.deviceInfo).toEqual({ platform: 'web' }); + }); + }); + + describe('revokeDevice', () => { + it('calls DELETE /auth/devices/:fingerprint', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.revokeDevice('fp-xyz'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz'); + expect(opts.method).toBe('DELETE'); + }); + }); + + describe('revokeAllDevices', () => { + it('calls POST /auth/devices/revoke-all', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.revokeAllDevices(); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all'); + expect(opts.method).toBe('POST'); + }); + }); + + // ── Phase 5B: Admin security ────────────────────── + + describe('getSecurityOverview', () => { + it('calls GET /auth/security/overview', async () => { + const overview = { + totalUsers: 100, + mfaAdoptionPercent: 42.5, + providerDistribution: { google: 60, microsoft: 30, password: 10 }, + activeSessions: 250, + suspiciousEvents24h: 3, + }; + globalThis.fetch = mockFetchResponse(overview); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('admin-tok', 'admin-ref'); + + const result = await client.getSecurityOverview(); + expect(result.totalUsers).toBe(100); + expect(result.mfaAdoptionPercent).toBe(42.5); + }); + }); + + describe('unlockUser', () => { + it('calls POST /auth/users/:id/unlock', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('admin-tok', 'admin-ref'); + + await client.unlockUser('user-locked-123'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock'); + expect(opts.method).toBe('POST'); + }); + }); + + describe('cancelDeletion', () => { + it('calls POST /auth/account/cancel-deletion', async () => { + globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' }); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + const result = await client.cancelDeletion(); + expect(result.message).toBe('Deletion cancelled'); + }); + }); + + // ── login() MFA flow ────────────────────────────── + + describe('login with MFA challenge', () => { + it('returns MfaLoginResult and does not store tokens', async () => { + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'login-challenge', + methods: ['totp', 'recovery'], + }; + globalThis.fetch = mockFetchResponse(mfaResponse); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + const result = await client.login('user@test.com', 'password'); + + expect('mfaRequired' in result).toBe(true); + if ('mfaRequired' in result) { + expect(result.mfaChallenge).toBe('login-challenge'); + expect(result.methods).toEqual(['totp', 'recovery']); + } + expect(client.isAuthenticated()).toBe(false); + }); + }); + + // ── createAuthClient includes all SmartAuth methods ── + + describe('client exposes all SmartAuth methods', () => { + it('has all phase 1C, 2D, 3, 5B methods', () => { + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + + // Phase 1C + expect(client.loginWithGoogle).toBeTypeOf('function'); + expect(client.loginWithMicrosoft).toBeTypeOf('function'); + expect(client.loginWithApple).toBeTypeOf('function'); + expect(client.getProviders).toBeTypeOf('function'); + expect(client.linkProvider).toBeTypeOf('function'); + expect(client.unlinkProvider).toBeTypeOf('function'); + // Phase 2D + expect(client.verifyMfa).toBeTypeOf('function'); + expect(client.setupTotp).toBeTypeOf('function'); + expect(client.verifyTotpSetup).toBeTypeOf('function'); + expect(client.disableMfa).toBeTypeOf('function'); + expect(client.getMfaStatus).toBeTypeOf('function'); + expect(client.regenerateRecoveryCodes).toBeTypeOf('function'); + // Phase 3 + expect(client.getPasskeyRegisterOptions).toBeTypeOf('function'); + expect(client.verifyPasskeyRegistration).toBeTypeOf('function'); + expect(client.getPasskeyAuthOptions).toBeTypeOf('function'); + expect(client.verifyPasskeyAuth).toBeTypeOf('function'); + expect(client.listPasskeys).toBeTypeOf('function'); + expect(client.deletePasskey).toBeTypeOf('function'); + expect(client.listDevices).toBeTypeOf('function'); + expect(client.trustDevice).toBeTypeOf('function'); + expect(client.revokeDevice).toBeTypeOf('function'); + expect(client.revokeAllDevices).toBeTypeOf('function'); + // Phase 5B + expect(client.getSecurityOverview).toBeTypeOf('function'); + expect(client.unlockUser).toBeTypeOf('function'); + expect(client.exportAuthData).toBeTypeOf('function'); + expect(client.cancelDeletion).toBeTypeOf('function'); + }); + }); +}); diff --git a/vendor/bytelyst/auth-client/src/client.ts b/vendor/bytelyst/auth-client/src/client.ts new file mode 100644 index 0000000..10e1161 --- /dev/null +++ b/vendor/bytelyst/auth-client/src/client.ts @@ -0,0 +1,527 @@ +/** + * 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, + AuthProvider, + AuthResult, + AuthUser, + Device, + LoginEventInfo, + MfaRequiredResult, + MfaStatus, + Passkey, + SecurityOverview, + TokenStorage, + TotpSetupResult, +} from './types.js'; + +// ── Default localStorage adapter ───────────────────────────────── + +/** + * No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js). + * Tokens stored via noopStorage are NOT persisted — they are lost on page reload. + * For server-side rendering, use cookie-based auth instead of relying on this client. + */ +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, + }); + if ('mfaRequired' in result && result.mfaRequired) { + return result; + } + const authResult = result as AuthResult; + setTokens(authResult.accessToken, authResult.refreshToken); + return authResult; + } + + 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, + }); + } + + // ── OAuth / Social login (Phase 1C) ──────────────── + + async function loginWithOAuth( + provider: string, + idToken: string + ): Promise { + const result = await request( + `/auth/oauth/${provider}`, + 'POST', + { idToken, productId }, + { skipAuth: true } + ); + if ('mfaRequired' in result && result.mfaRequired) { + return result; + } + const authResult = result as AuthResult; + setTokens(authResult.accessToken, authResult.refreshToken); + return authResult; + } + + async function loginWithGoogle(idToken: string): Promise { + return loginWithOAuth('google', idToken); + } + + async function loginWithMicrosoft(idToken: string): Promise { + return loginWithOAuth('microsoft', idToken); + } + + async function loginWithApple(idToken: string): Promise { + return loginWithOAuth('apple', idToken); + } + + // ── Provider management (Phase 1C) ───────────────── + + async function getProviders(): Promise { + const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET'); + return data.providers; + } + + async function linkProvider(provider: string, idToken: string): Promise { + await request('/auth/providers/link', 'POST', { provider, idToken }); + } + + async function unlinkProvider(provider: string): Promise { + await request(`/auth/providers/${provider}`, 'DELETE'); + } + + // ── MFA (Phase 2D) ───────────────────────────────── + + async function verifyMfa( + challengeToken: string, + code: string, + method: 'totp' | 'recovery' + ): Promise { + const result = await request('/auth/mfa/verify', 'POST', { + challengeToken, + code, + method, + }); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function setupTotp(): Promise { + return request('/auth/mfa/setup', 'POST'); + } + + async function verifyTotpSetup(code: string): Promise { + await request('/auth/mfa/verify-setup', 'POST', { code }); + } + + async function disableMfa(code: string): Promise { + await request('/auth/mfa/disable', 'POST', { code }); + } + + async function getMfaStatus(): Promise { + return request('/auth/mfa/status', 'GET'); + } + + async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> { + return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST'); + } + + // ── Passkeys (Phase 3) ───────────────────────────── + + async function getPasskeyRegisterOptions(): Promise { + return request('/auth/passkeys/register/options', 'POST'); + } + + async function verifyPasskeyRegistration(response: unknown): Promise { + await request('/auth/passkeys/register/verify', 'POST', response); + } + + async function getPasskeyAuthOptions(): Promise { + return request('/auth/passkeys/authenticate/options', 'POST', undefined, { + skipAuth: true, + }); + } + + async function verifyPasskeyAuth(response: unknown): Promise { + const result = await request( + '/auth/passkeys/authenticate/verify', + 'POST', + response, + { skipAuth: true } + ); + setTokens(result.accessToken, result.refreshToken); + return result; + } + + async function listPasskeys(): Promise { + const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET'); + return data.passkeys; + } + + async function deletePasskey(id: string): Promise { + await request(`/auth/passkeys/${id}`, 'DELETE'); + } + + // ── Devices (Phase 3) ────────────────────────────── + + async function listDevices(): Promise { + const data = await request<{ devices: Device[] }>('/auth/devices', 'GET'); + return data.devices; + } + + async function trustDevice( + fingerprint: string, + trustLevel: 'trusted' | 'remembered', + deviceInfo?: Record + ): Promise { + await request('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo }); + } + + async function revokeDevice(fingerprint: string): Promise { + await request(`/auth/devices/${fingerprint}`, 'DELETE'); + } + + async function revokeAllDevices(): Promise { + await request('/auth/devices/revoke-all', 'POST'); + } + + // ── Admin security (Phase 5B) ────────────────────── + + async function getSecurityOverview(): Promise { + return request('/auth/security/overview', 'GET'); + } + + async function unlockUser(userId: string): Promise { + await request(`/auth/users/${userId}/unlock`, 'POST'); + } + + async function exportAuthData(): Promise { + return request('/auth/export', 'GET'); + } + + async function cancelDeletion(): Promise<{ message: string }> { + return request<{ message: string }>('/auth/account/cancel-deletion', 'POST'); + } + + // ── Step-up auth ──────────────────────────────────── + + async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> { + return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential }); + } + + // ── Login history ─────────────────────────────────── + + async function getLoginHistory(limit = 50): Promise { + const data = await request<{ events: LoginEventInfo[] }>( + `/auth/login-events?limit=${limit}`, + 'GET' + ); + return data.events; + } + + // ── Admin security ────────────────────────────────── + + async function getAdminLoginEvents(opts?: { + userId?: string; + suspicious?: boolean; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + if (opts?.userId) params.set('userId', opts.userId); + if (opts?.suspicious) params.set('suspicious', 'true'); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + const data = await request<{ events: LoginEventInfo[] }>( + `/auth/login-events/admin${qs ? `?${qs}` : ''}`, + 'GET' + ); + return data.events; + } + + async function getAdminDevices(userId: string): Promise { + const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET'); + return data.devices; + } + + return { + getAccessToken, + getRefreshToken, + setTokens, + clearTokens, + isAuthenticated, + login, + register, + getMe, + refreshAccessToken, + forgotPassword, + resetPassword, + changePassword, + deleteAccount, + verifyEmail, + resendVerification, + // OAuth / Social login (Phase 1C) + loginWithGoogle, + loginWithMicrosoft, + loginWithApple, + // Provider management (Phase 1C) + getProviders, + linkProvider, + unlinkProvider, + // MFA (Phase 2D) + verifyMfa, + setupTotp, + verifyTotpSetup, + disableMfa, + getMfaStatus, + regenerateRecoveryCodes, + // Passkeys (Phase 3) + getPasskeyRegisterOptions, + verifyPasskeyRegistration, + getPasskeyAuthOptions, + verifyPasskeyAuth, + listPasskeys, + deletePasskey, + // Devices (Phase 3) + listDevices, + trustDevice, + revokeDevice, + revokeAllDevices, + // Admin security (Phase 5B) + getSecurityOverview, + unlockUser, + exportAuthData, + cancelDeletion, + // Step-up auth + stepUp, + // Login history + getLoginHistory, + // Admin queries + getAdminLoginEvents, + getAdminDevices, + }; +} diff --git a/vendor/bytelyst/auth-client/src/index.ts b/vendor/bytelyst/auth-client/src/index.ts new file mode 100644 index 0000000..9592e2e --- /dev/null +++ b/vendor/bytelyst/auth-client/src/index.ts @@ -0,0 +1,16 @@ +export { createAuthClient } from './client.js'; +export type { + AuthClient, + AuthClientConfig, + AuthProvider, + AuthResult, + AuthUser, + Device, + LoginEventInfo, + MfaRequiredResult, + MfaStatus, + Passkey, + SecurityOverview, + TokenStorage, + TotpSetupResult, +} from './types.js'; diff --git a/vendor/bytelyst/auth-client/src/types.ts b/vendor/bytelyst/auth-client/src/types.ts new file mode 100644 index 0000000..3f760b5 --- /dev/null +++ b/vendor/bytelyst/auth-client/src/types.ts @@ -0,0 +1,190 @@ +/** + * 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; + mfaEnabled?: boolean; + mfaMethods?: string[]; + providers?: string[]; + products?: string[]; +} + +export interface AuthResult { + accessToken: string; + refreshToken: string; + user: AuthUser; +} + +export interface MfaRequiredResult { + mfaRequired: true; + mfaChallenge: string; + methods: string[]; +} + +export interface TotpSetupResult { + secret: string; + otpauthUri: string; + recoveryCodes: string[]; +} + +export interface MfaStatus { + mfaEnabled: boolean; + methods: string[]; + recoveryCodesRemaining: number; +} + +export interface AuthProvider { + provider: string; + email: string; + linkedAt: string; + lastUsedAt: string | null; +} + +export interface Passkey { + credentialId: string; + friendlyName: string; + deviceType: string; + backedUp: boolean; + lastUsedAt: string; + createdAt: string; +} + +export interface Device { + fingerprint: string; + trustLevel: 'trusted' | 'remembered' | 'unknown'; + deviceInfo: Record; + lastIp?: string; + lastLocation?: string; + trustExpiresAt: string; + createdAt: string; + lastSeenAt: string; + isTrusted: boolean; +} + +export interface LoginEventInfo { + id: string; + result: string; + method: string; + ip: string; + userAgent?: string; + fingerprint?: string; + location?: string; + riskLevel: string; + riskScore: number; + riskFlags: string[]; + createdAt: string; +} + +export interface SecurityOverview { + totalUsers: number; + mfaAdoptionPercent: number; + providerDistribution: Record; + activeSessions: number; + suspiciousEvents24h: number; +} + +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; + + // ── OAuth / Social login ──────────────────────── + loginWithGoogle(idToken: string): Promise; + loginWithMicrosoft(idToken: string): Promise; + loginWithApple(idToken: string): Promise; + + // ── Provider management ───────────────────────── + getProviders(): Promise; + linkProvider(provider: string, idToken: string): Promise; + unlinkProvider(provider: string): Promise; + + // ── MFA ───────────────────────────────────────── + verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise; + setupTotp(): Promise; + verifyTotpSetup(code: string): Promise; + disableMfa(code: string): Promise; + getMfaStatus(): Promise; + regenerateRecoveryCodes(): Promise<{ codes: string[] }>; + + // ── Passkeys (WebAuthn) ───────────────────────── + getPasskeyRegisterOptions(): Promise; + verifyPasskeyRegistration(response: unknown): Promise; + getPasskeyAuthOptions(): Promise; + verifyPasskeyAuth(response: unknown): Promise; + listPasskeys(): Promise; + deletePasskey(id: string): Promise; + + // ── Devices ───────────────────────────────────── + listDevices(): Promise; + trustDevice( + fingerprint: string, + trustLevel: 'trusted' | 'remembered', + deviceInfo?: Record + ): Promise; + revokeDevice(fingerprint: string): Promise; + revokeAllDevices(): Promise; + + // ── Step-up auth ──────────────────────────────── + stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>; + + // ── Login history ─────────────────────────────── + getLoginHistory(limit?: number): Promise; + + // ── Admin security ────────────────────────────── + getSecurityOverview(): Promise; + unlockUser(userId: string): Promise; + getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise; + getAdminDevices(userId: string): Promise; + exportAuthData(): 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 }>; + cancelDeletion(): Promise<{ message: string }>; + + // ── Email verification ────────────────────────── + verifyEmail(token: string): Promise<{ message: string }>; + resendVerification(email: string): Promise<{ message: string }>; +} diff --git a/vendor/bytelyst/auth-client/tsconfig.json b/vendor/bytelyst/auth-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/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/vendor/bytelyst/auth-ui/package.json b/vendor/bytelyst/auth-ui/package.json new file mode 100644 index 0000000..3f6285a --- /dev/null +++ b/vendor/bytelyst/auth-ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@bytelyst/auth-ui", + "version": "0.1.5", + "type": "module", + "description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)", + "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 --pool forks" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx b/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx new file mode 100644 index 0000000..26a28d2 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx @@ -0,0 +1,101 @@ +import type { AuthPageLayoutProps } from './types.js'; + +/** + * Full-page auth layout — centered card with product branding. + * Wraps any auth form (LoginForm, RegisterForm, etc.). + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function AuthPageLayout({ + productName, + logo, + title, + subtitle, + children, + footer, + className, +}: AuthPageLayoutProps) { + return ( +
+
+ {/* Branding */} +
+ {logo && ( +
+ {typeof logo === 'string' ? ( + {productName} + ) : ( + logo + )} +
+ )} +
+ {productName} +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ + {/* Form content */} + {children} + + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx new file mode 100644 index 0000000..94c7125 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx @@ -0,0 +1,111 @@ +import { useState, type FormEvent } from 'react'; +import type { ForgotPasswordFormProps } from './types.js'; + +/** + * Forgot password form — email input to request a reset link. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function ForgotPasswordForm({ + onSubmit, + isLoading = false, + error, + success, + onBack, + className, +}: ForgotPasswordFormProps) { + const [email, setEmail] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(email); + } + + const inputStyle = { + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + width: '100%', + boxSizing: 'border-box' as const, + }; + + return ( +
+
+
+ Enter your email address and we'll send you a link to reset your password. +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + data-testid="bl-forgot-email" + style={inputStyle} + /> + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + + + {onBack && ( + + )} +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/LoginForm.tsx b/vendor/bytelyst/auth-ui/src/LoginForm.tsx new file mode 100644 index 0000000..d4fbbc8 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/LoginForm.tsx @@ -0,0 +1,116 @@ +import { useState, type FormEvent } from 'react'; +import { SocialButtons } from './SocialButtons.js'; +import type { LoginFormProps } from './types.js'; + +/** + * Email/password login form with optional social login buttons. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function LoginForm({ + onSubmit, + providers, + onSocialLogin, + isLoading = false, + error, + className, +}: LoginFormProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(email, password); + } + + return ( +
+
+ setEmail(e.target.value)} + required + disabled={isLoading} + data-testid="bl-login-email" + style={{ + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + }} + /> + setPassword(e.target.value)} + required + disabled={isLoading} + data-testid="bl-login-password" + style={{ + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + }} + /> + + {error && ( +
+ {error} +
+ )} + + +
+ + {providers && providers.length > 0 && onSocialLogin && ( + <> +
+
+ or +
+
+ + + )} +
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx b/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx new file mode 100644 index 0000000..289cfe0 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx @@ -0,0 +1,114 @@ +import { useState, type FormEvent } from 'react'; +import type { MfaChallengeProps } from './types.js'; + +/** + * MFA code entry form (6-digit TOTP or recovery code). + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function MfaChallenge({ + onSubmit, + onUseRecovery, + methods, + isLoading = false, + error, + className, +}: MfaChallengeProps) { + const [code, setCode] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(code); + } + + return ( +
+
+
+ Enter your authentication code +
+ + {methods && methods.length > 0 && ( +
+ Available methods: {methods.join(', ')} +
+ )} + + setCode(e.target.value)} + required + disabled={isLoading} + maxLength={8} + data-testid="bl-mfa-code" + style={{ + padding: '12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '24px', + textAlign: 'center', + letterSpacing: '4px', + fontFamily: 'monospace', + }} + /> + + {error && ( +
+ {error} +
+ )} + + + + {onUseRecovery && ( + + )} +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx b/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx new file mode 100644 index 0000000..a0d9b5a --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx @@ -0,0 +1,148 @@ +import type { OnboardingShellProps } from './types.js'; + +/** + * Onboarding shell — step indicator, navigation, progress bar. + * Wraps arbitrary step content provided via children. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function OnboardingShell({ + steps, + currentStep, + onNext, + onBack, + onComplete, + children, + className, +}: OnboardingShellProps) { + const isFirst = currentStep === 0; + const isLast = currentStep === steps.length - 1; + const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100; + + return ( +
+ {/* Progress bar */} +
+
+
+ + {/* Step indicator */} +
+ {steps.map((step, i) => ( +
+ + {i < currentStep ? '✓' : i + 1} + + {step.label} +
+ ))} +
+ + {/* Step content */} +
+ {children} +
+ + {/* Navigation */} +
+ + + +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx b/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx new file mode 100644 index 0000000..9c3e2b6 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx @@ -0,0 +1,67 @@ +import { useMemo } from 'react'; +import type { PasswordStrength } from './types.js'; + +interface PasswordStrengthBarProps { + password: string; + className?: string; +} + +const STRENGTH_CONFIG: Record = { + weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' }, + fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' }, + good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' }, + strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' }, +}; + +export function getPasswordStrength(password: string): PasswordStrength { + let score = 0; + if (password.length >= 8) score++; + if (password.length >= 12) score++; + if (/[A-Z]/.test(password)) score++; + if (/[a-z]/.test(password)) score++; + if (/\d/.test(password)) score++; + if (/[^A-Za-z0-9]/.test(password)) score++; + + if (score <= 2) return 'weak'; + if (score <= 3) return 'fair'; + if (score <= 4) return 'good'; + return 'strong'; +} + +export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) { + const strength = useMemo(() => getPasswordStrength(password), [password]); + const config = STRENGTH_CONFIG[strength]; + const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength]; + + if (!password) return null; + + return ( +
+
+
+
+
+ {config.label} +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/RegisterForm.tsx b/vendor/bytelyst/auth-ui/src/RegisterForm.tsx new file mode 100644 index 0000000..0ab1b1b --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/RegisterForm.tsx @@ -0,0 +1,226 @@ +import { useState, type FormEvent } from 'react'; +import { PasswordStrengthBar } from './PasswordStrengthBar.js'; +import type { RegisterFormProps } from './types.js'; + +/** + * Registration form with name, email, password, confirm password, + * password strength indicator, and optional terms checkbox. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function RegisterForm({ + onSubmit, + isLoading = false, + error, + termsUrl, + privacyUrl, + onSwitchToLogin, + className, +}: RegisterFormProps) { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [termsAccepted, setTermsAccepted] = useState(!termsUrl); + + const passwordMismatch = confirm.length > 0 && password !== confirm; + const canSubmit = + name.trim().length > 0 && + email.length > 0 && + password.length >= 8 && + !passwordMismatch && + termsAccepted && + !isLoading; + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + onSubmit({ name: name.trim(), email, password }); + } + + const inputStyle = { + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + width: '100%', + boxSizing: 'border-box' as const, + }; + + return ( +
+
+ + + + + + + + + + + {passwordMismatch && ( +
+ Passwords do not match +
+ )} + + {termsUrl && ( + + )} + + {error && ( +
+ {error} +
+ )} + + + + {onSwitchToLogin && ( +
+ Already have an account?{' '} + +
+ )} + +
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx new file mode 100644 index 0000000..bd6454e --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx @@ -0,0 +1,131 @@ +import { useState, type FormEvent } from 'react'; +import { PasswordStrengthBar } from './PasswordStrengthBar.js'; +import type { ResetPasswordFormProps } from './types.js'; + +/** + * Reset password form — new password + confirm, with strength indicator. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function ResetPasswordForm({ + onSubmit, + isLoading = false, + error, + success, + className, +}: ResetPasswordFormProps) { + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + + const passwordMismatch = confirm.length > 0 && password !== confirm; + const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading; + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + onSubmit(password); + } + + const inputStyle = { + padding: '10px 12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '14px', + width: '100%', + boxSizing: 'border-box' as const, + }; + + return ( +
+
+
+ Enter your new password. +
+ + + + + + + + {passwordMismatch && ( +
+ Passwords do not match +
+ )} + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + + +
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/SocialButtons.tsx b/vendor/bytelyst/auth-ui/src/SocialButtons.tsx new file mode 100644 index 0000000..8e0a67a --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/SocialButtons.tsx @@ -0,0 +1,48 @@ +import type { SocialButtonsProps, SocialProvider } from './types.js'; + +const PROVIDER_LABELS: Record = { + google: 'Google', + microsoft: 'Microsoft', + apple: 'Apple', +}; + +/** + * Renders social login buttons for the configured providers. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function SocialButtons({ + providers, + onSelect, + disabled = false, + className, +}: SocialButtonsProps) { + return ( +
+ {providers.map(provider => ( + + ))} +
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx b/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx new file mode 100644 index 0000000..fce0746 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx @@ -0,0 +1,117 @@ +import { useState, type FormEvent } from 'react'; +import type { VerifyEmailFormProps } from './types.js'; + +/** + * Email verification form — 6-digit code input with resend option. + * Styled via CSS custom properties (inherits --bl-* from host app). + */ +export function VerifyEmailForm({ + onSubmit, + onResend, + isLoading = false, + error, + success, + email, + className, +}: VerifyEmailFormProps) { + const [code, setCode] = useState(''); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + onSubmit(code); + } + + return ( +
+
+
+ Enter the 6-digit code sent to {email ? {email} : 'your email'}. +
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + disabled={isLoading} + maxLength={6} + data-testid="bl-verify-code" + style={{ + padding: '12px', + border: '1px solid var(--bl-border, #ccc)', + borderRadius: 'var(--bl-radius, 6px)', + fontSize: '24px', + textAlign: 'center', + letterSpacing: '6px', + fontFamily: 'monospace', + width: '100%', + boxSizing: 'border-box', + }} + /> + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + + + {onResend && ( + + )} +
+
+ ); +} diff --git a/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx new file mode 100644 index 0000000..81eb973 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { LoginForm } from '../LoginForm.js'; +import { MfaChallenge } from '../MfaChallenge.js'; +import { SocialButtons } from '../SocialButtons.js'; + +describe('@bytelyst/auth-ui', () => { + beforeEach(() => { + cleanup(); + }); + + describe('SocialButtons', () => { + it('renders buttons for each provider', () => { + const onSelect = vi.fn(); + render(); + + expect(screen.getByTestId('bl-social-google')).toBeDefined(); + expect(screen.getByTestId('bl-social-microsoft')).toBeDefined(); + expect(screen.getByTestId('bl-social-apple')).toBeDefined(); + expect(screen.getByText('Continue with Google')).toBeDefined(); + expect(screen.getByText('Continue with Microsoft')).toBeDefined(); + expect(screen.getByText('Continue with Apple')).toBeDefined(); + }); + + it('calls onSelect with provider when clicked', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('bl-social-google')); + expect(onSelect).toHaveBeenCalledWith('google'); + }); + + it('disables buttons when disabled prop is true', () => { + const onSelect = vi.fn(); + render(); + + const btn = screen.getByTestId('bl-social-google'); + expect(btn.getAttribute('disabled')).toBe(''); + }); + }); + + describe('LoginForm', () => { + it('renders email, password, and submit button', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-login-email')).toBeDefined(); + expect(screen.getByTestId('bl-login-password')).toBeDefined(); + expect(screen.getByTestId('bl-login-submit')).toBeDefined(); + expect(screen.getByText('Sign in')).toBeDefined(); + }); + + it('calls onSubmit with email and password', () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId('bl-login-email'), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByTestId('bl-login-password'), { + target: { value: 'password123' }, + }); + fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!); + + expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123'); + }); + + it('displays error message', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-login-error')).toBeDefined(); + expect(screen.getByText('Invalid credentials')).toBeDefined(); + }); + + it('renders social buttons when providers are given', () => { + const onSubmit = vi.fn(); + const onSocialLogin = vi.fn(); + render( + + ); + + expect(screen.getByTestId('bl-social-google')).toBeDefined(); + expect(screen.getByTestId('bl-social-apple')).toBeDefined(); + + fireEvent.click(screen.getByTestId('bl-social-google')); + expect(onSocialLogin).toHaveBeenCalledWith('google'); + }); + + it('shows loading state', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByText('Signing in...')).toBeDefined(); + const btn = screen.getByTestId('bl-login-submit'); + expect(btn.getAttribute('disabled')).toBe(''); + }); + }); + + describe('MfaChallenge', () => { + it('renders code input and verify button', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-code')).toBeDefined(); + expect(screen.getByTestId('bl-mfa-submit')).toBeDefined(); + expect(screen.getByText('Verify')).toBeDefined(); + }); + + it('calls onSubmit with code', () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId('bl-mfa-code'), { + target: { value: '123456' }, + }); + fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!); + + expect(onSubmit).toHaveBeenCalledWith('123456'); + }); + + it('displays available methods', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-methods')).toBeDefined(); + expect(screen.getByText('Available methods: totp, recovery')).toBeDefined(); + }); + + it('shows recovery code button when handler provided', () => { + const onSubmit = vi.fn(); + const onUseRecovery = vi.fn(); + render(); + + const recoveryBtn = screen.getByTestId('bl-mfa-recovery'); + expect(recoveryBtn).toBeDefined(); + + fireEvent.click(recoveryBtn); + expect(onUseRecovery).toHaveBeenCalledOnce(); + }); + + it('displays error message', () => { + const onSubmit = vi.fn(); + render(); + + expect(screen.getByTestId('bl-mfa-error')).toBeDefined(); + expect(screen.getByText('Invalid code')).toBeDefined(); + }); + }); +}); diff --git a/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx new file mode 100644 index 0000000..6134b61 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx @@ -0,0 +1,402 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { RegisterForm } from '../RegisterForm.js'; +import { ForgotPasswordForm } from '../ForgotPasswordForm.js'; +import { ResetPasswordForm } from '../ResetPasswordForm.js'; +import { VerifyEmailForm } from '../VerifyEmailForm.js'; +import { OnboardingShell } from '../OnboardingShell.js'; +import { AuthPageLayout } from '../AuthPageLayout.js'; +import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js'; + +describe('RegisterForm', () => { + beforeEach(() => cleanup()); + + it('renders all fields', () => { + render(); + expect(screen.getByTestId('bl-register-name')).toBeDefined(); + expect(screen.getByTestId('bl-register-email')).toBeDefined(); + expect(screen.getByTestId('bl-register-password')).toBeDefined(); + expect(screen.getByTestId('bl-register-confirm')).toBeDefined(); + expect(screen.getByTestId('bl-register-submit')).toBeDefined(); + }); + + it('calls onSubmit with name, email, password', () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } }); + fireEvent.change(screen.getByTestId('bl-register-email'), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByTestId('bl-register-password'), { + target: { value: 'Password1!' }, + }); + fireEvent.change(screen.getByTestId('bl-register-confirm'), { + target: { value: 'Password1!' }, + }); + fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!); + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Alice', + email: 'alice@example.com', + password: 'Password1!', + }); + }); + + it('shows password mismatch error', () => { + render(); + fireEvent.change(screen.getByTestId('bl-register-password'), { + target: { value: 'Password1!' }, + }); + fireEvent.change(screen.getByTestId('bl-register-confirm'), { + target: { value: 'Different1!' }, + }); + + expect(screen.getByTestId('bl-register-mismatch')).toBeDefined(); + expect(screen.getByText('Passwords do not match')).toBeDefined(); + }); + + it('displays error message', () => { + render(); + expect(screen.getByTestId('bl-register-error')).toBeDefined(); + expect(screen.getByText('Email already taken')).toBeDefined(); + }); + + it('shows terms checkbox when termsUrl provided', () => { + render(); + expect(screen.getByTestId('bl-register-terms')).toBeDefined(); + expect(screen.getByText('Terms of Service')).toBeDefined(); + }); + + it('renders switch to login link', () => { + const onSwitch = vi.fn(); + render(); + const link = screen.getByTestId('bl-register-switch-login'); + fireEvent.click(link); + expect(onSwitch).toHaveBeenCalledOnce(); + }); + + it('shows loading state', () => { + render(); + expect(screen.getByText('Creating account...')).toBeDefined(); + }); + + it('shows password strength bar when typing', () => { + render(); + fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } }); + expect(screen.getByTestId('bl-password-strength')).toBeDefined(); + }); +}); + +describe('ForgotPasswordForm', () => { + beforeEach(() => cleanup()); + + it('renders email input and submit', () => { + render(); + expect(screen.getByTestId('bl-forgot-email')).toBeDefined(); + expect(screen.getByTestId('bl-forgot-submit')).toBeDefined(); + }); + + it('calls onSubmit with email', () => { + const onSubmit = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('bl-forgot-email'), { + target: { value: 'test@example.com' }, + }); + fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!); + expect(onSubmit).toHaveBeenCalledWith('test@example.com'); + }); + + it('displays error message', () => { + render(); + expect(screen.getByTestId('bl-forgot-error')).toBeDefined(); + }); + + it('displays success message', () => { + render(); + expect(screen.getByTestId('bl-forgot-success')).toBeDefined(); + expect(screen.getByText('Check your email')).toBeDefined(); + }); + + it('renders back button and calls onBack', () => { + const onBack = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-forgot-back')); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it('shows loading state', () => { + render(); + expect(screen.getByText('Sending...')).toBeDefined(); + }); +}); + +describe('ResetPasswordForm', () => { + beforeEach(() => cleanup()); + + it('renders password fields and submit', () => { + render(); + expect(screen.getByTestId('bl-reset-password')).toBeDefined(); + expect(screen.getByTestId('bl-reset-confirm')).toBeDefined(); + expect(screen.getByTestId('bl-reset-submit')).toBeDefined(); + }); + + it('calls onSubmit with password', () => { + const onSubmit = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); + fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } }); + fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!); + expect(onSubmit).toHaveBeenCalledWith('NewPass1!'); + }); + + it('shows mismatch error', () => { + render(); + fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); + fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } }); + expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined(); + }); + + it('displays error and success messages', () => { + const { rerender } = render(); + expect(screen.getByTestId('bl-reset-error')).toBeDefined(); + + rerender(); + expect(screen.getByTestId('bl-reset-success')).toBeDefined(); + }); + + it('shows password strength bar', () => { + render(); + fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } }); + expect(screen.getByTestId('bl-password-strength')).toBeDefined(); + }); +}); + +describe('VerifyEmailForm', () => { + beforeEach(() => cleanup()); + + it('renders code input and submit', () => { + render(); + expect(screen.getByTestId('bl-verify-code')).toBeDefined(); + expect(screen.getByTestId('bl-verify-submit')).toBeDefined(); + }); + + it('calls onSubmit with code', () => { + const onSubmit = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } }); + fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!); + expect(onSubmit).toHaveBeenCalledWith('123456'); + }); + + it('displays email address', () => { + render(); + expect(screen.getByText('test@example.com')).toBeDefined(); + }); + + it('renders resend button', () => { + const onResend = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-verify-resend')); + expect(onResend).toHaveBeenCalledOnce(); + }); + + it('displays error and success messages', () => { + const { rerender } = render(); + expect(screen.getByTestId('bl-verify-error')).toBeDefined(); + + rerender(); + expect(screen.getByTestId('bl-verify-success')).toBeDefined(); + }); + + it('strips non-numeric characters', () => { + render(); + const input = screen.getByTestId('bl-verify-code'); + fireEvent.change(input, { target: { value: 'abc123def456' } }); + expect((input as unknown as { value: string }).value).toBe('123456'); + }); +}); + +describe('OnboardingShell', () => { + beforeEach(() => cleanup()); + + const steps = [ + { key: 'welcome', label: 'Welcome' }, + { key: 'profile', label: 'Profile' }, + { key: 'preferences', label: 'Preferences' }, + ]; + + it('renders steps and content', () => { + render( + +
Step 1 content
+
+ ); + expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined(); + expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined(); + expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined(); + expect(screen.getByText('Step 1 content')).toBeDefined(); + expect(screen.getByText('Welcome')).toBeDefined(); + expect(screen.getByText('Profile')).toBeDefined(); + }); + + it('disables Back on first step', () => { + render( + +
+ + ); + const back = screen.getByTestId('bl-onboarding-back'); + expect(back.getAttribute('disabled')).toBe(''); + }); + + it('calls onNext on middle step', () => { + const onNext = vi.fn(); + render( + +
+ + ); + fireEvent.click(screen.getByTestId('bl-onboarding-next')); + expect(onNext).toHaveBeenCalledOnce(); + }); + + it('shows Complete on last step and calls onComplete', () => { + const onComplete = vi.fn(); + render( + +
+ + ); + const btn = screen.getByTestId('bl-onboarding-complete'); + expect(btn.textContent).toBe('Complete'); + fireEvent.click(btn); + expect(onComplete).toHaveBeenCalledOnce(); + }); + + it('calls onBack on non-first step', () => { + const onBack = vi.fn(); + render( + +
+ + ); + fireEvent.click(screen.getByTestId('bl-onboarding-back')); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); + +describe('AuthPageLayout', () => { + beforeEach(() => cleanup()); + + it('renders product name and title', () => { + render( + +
Form content
+
+ ); + expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp'); + expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In'); + expect(screen.getByText('Form content')).toBeDefined(); + }); + + it('renders subtitle when provided', () => { + render( + +
+ + ); + expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back'); + }); + + it('renders logo as element', () => { + render( + Logo} + > +
+ + ); + expect(screen.getByTestId('custom-logo')).toBeDefined(); + }); + + it('renders footer', () => { + render( + Footer text}> +
+ + ); + expect(screen.getByTestId('bl-auth-footer')).toBeDefined(); + expect(screen.getByText('Footer text')).toBeDefined(); + }); +}); + +describe('PasswordStrengthBar', () => { + beforeEach(() => cleanup()); + + it('returns null for empty password', () => { + const { container } = render(); + expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull(); + }); + + it('shows Weak for short password', () => { + render(); + expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak'); + }); + + it('shows Strong for complex password', () => { + render(); + expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong'); + }); +}); + +describe('getPasswordStrength', () => { + it('returns weak for very short passwords', () => { + expect(getPasswordStrength('ab')).toBe('weak'); + }); + + it('returns fair for medium passwords', () => { + expect(getPasswordStrength('abcdefgh1')).toBe('fair'); + }); + + it('returns good for decent passwords', () => { + expect(getPasswordStrength('Abcdefgh1')).toBe('good'); + }); + + it('returns strong for complex passwords', () => { + expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong'); + }); +}); diff --git a/vendor/bytelyst/auth-ui/src/index.ts b/vendor/bytelyst/auth-ui/src/index.ts new file mode 100644 index 0000000..e24f874 --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/index.ts @@ -0,0 +1,24 @@ +export { LoginForm } from './LoginForm.js'; +export { RegisterForm } from './RegisterForm.js'; +export { ForgotPasswordForm } from './ForgotPasswordForm.js'; +export { ResetPasswordForm } from './ResetPasswordForm.js'; +export { VerifyEmailForm } from './VerifyEmailForm.js'; +export { MfaChallenge } from './MfaChallenge.js'; +export { SocialButtons } from './SocialButtons.js'; +export { OnboardingShell } from './OnboardingShell.js'; +export { AuthPageLayout } from './AuthPageLayout.js'; +export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js'; +export type { + LoginFormProps, + RegisterFormProps, + ForgotPasswordFormProps, + ResetPasswordFormProps, + VerifyEmailFormProps, + MfaChallengeProps, + SocialButtonsProps, + SocialProvider, + OnboardingShellProps, + OnboardingStep, + AuthPageLayoutProps, + PasswordStrength, +} from './types.js'; diff --git a/vendor/bytelyst/auth-ui/src/types.ts b/vendor/bytelyst/auth-ui/src/types.ts new file mode 100644 index 0000000..706f44f --- /dev/null +++ b/vendor/bytelyst/auth-ui/src/types.ts @@ -0,0 +1,147 @@ +export type SocialProvider = 'google' | 'microsoft' | 'apple'; + +export interface LoginFormProps { + /** Called when user submits email/password. */ + onSubmit: (email: string, password: string) => void; + /** Social providers to display. */ + providers?: SocialProvider[]; + /** Called when user clicks a social login button. */ + onSocialLogin?: (provider: SocialProvider) => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface MfaChallengeProps { + /** Called when user submits the MFA code. */ + onSubmit: (code: string) => void; + /** Called when user clicks "Use recovery code". */ + onUseRecovery?: () => void; + /** MFA methods available. */ + methods?: string[]; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface SocialButtonsProps { + /** Providers to display buttons for. */ + providers: SocialProvider[]; + /** Called when a provider button is clicked. */ + onSelect: (provider: SocialProvider) => void; + /** Whether the buttons are disabled. */ + disabled?: boolean; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface RegisterFormProps { + /** Called when user submits registration. */ + onSubmit: (data: { name: string; email: string; password: string }) => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Terms of service URL (renders checkbox if provided). */ + termsUrl?: string; + /** Privacy policy URL. */ + privacyUrl?: string; + /** Called when user clicks "Already have an account?" */ + onSwitchToLogin?: () => void; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface ForgotPasswordFormProps { + /** Called when user submits email for password reset. */ + onSubmit: (email: string) => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Success message (e.g., "Check your email"). */ + success?: string | null; + /** Called when user clicks "Back to login". */ + onBack?: () => void; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface ResetPasswordFormProps { + /** Called when user submits new password. */ + onSubmit: (password: string) => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Success message (e.g., "Password updated"). */ + success?: string | null; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface VerifyEmailFormProps { + /** Called when user submits the verification code. */ + onSubmit: (code: string) => void; + /** Called when user clicks "Resend code". */ + onResend?: () => void; + /** Whether the form is currently loading. */ + isLoading?: boolean; + /** Error message to display. */ + error?: string | null; + /** Success message (e.g., "Code resent"). */ + success?: string | null; + /** Email address being verified (for display). */ + email?: string; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface OnboardingStep { + /** Unique key for the step. */ + key: string; + /** Display label for the step indicator. */ + label: string; +} + +export interface OnboardingShellProps { + /** Ordered list of steps. */ + steps: OnboardingStep[]; + /** Index of the current step (0-based). */ + currentStep: number; + /** Called when user clicks Next. */ + onNext: () => void; + /** Called when user clicks Back. */ + onBack: () => void; + /** Called when the final step completes. */ + onComplete: () => void; + /** Content to render for the current step. */ + children: React.ReactNode; + /** Additional CSS class for the root element. */ + className?: string; +} + +export interface AuthPageLayoutProps { + /** Product name displayed at the top. */ + productName: string; + /** Optional logo element or URL. */ + logo?: React.ReactNode; + /** Page title (e.g., "Sign In", "Create Account"). */ + title: string; + /** Subtitle or description. */ + subtitle?: string; + /** Form content. */ + children: React.ReactNode; + /** Footer content (links, etc.). */ + footer?: React.ReactNode; + /** Additional CSS class for the root element. */ + className?: string; +} + +export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong'; diff --git a/vendor/bytelyst/auth-ui/tsconfig.json b/vendor/bytelyst/auth-ui/tsconfig.json new file mode 100644 index 0000000..4447784 --- /dev/null +++ b/vendor/bytelyst/auth-ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/vendor/bytelyst/auth-ui/vitest.config.ts b/vendor/bytelyst/auth-ui/vitest.config.ts new file mode 100644 index 0000000..cf32686 --- /dev/null +++ b/vendor/bytelyst/auth-ui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/auth/package.json b/vendor/bytelyst/auth/package.json index 28e2c24..86164e6 100644 --- a/vendor/bytelyst/auth/package.json +++ b/vendor/bytelyst/auth/package.json @@ -18,7 +18,7 @@ "test": "vitest run --pool forks" }, "dependencies": { - "@bytelyst/errors": "file:../errors" + "@bytelyst/errors": "workspace:*" }, "peerDependencies": { "jose": ">=5.0.0", diff --git a/vendor/bytelyst/backend-config/package.json b/vendor/bytelyst/backend-config/package.json new file mode 100644 index 0000000..fe64461 --- /dev/null +++ b/vendor/bytelyst/backend-config/package.json @@ -0,0 +1,33 @@ +{ + "name": "@bytelyst/backend-config", + "version": "0.1.5", + "description": "Shared Zod config schema base for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run --pool forks", + "clean": "rm -rf dist" + }, + "dependencies": { + "zod": "^3.24.2" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/backend-config/src/index.test.ts b/vendor/bytelyst/backend-config/src/index.test.ts new file mode 100644 index 0000000..61ab6b5 --- /dev/null +++ b/vendor/bytelyst/backend-config/src/index.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { baseBackendConfigSchema, parseBackendConfig } from './index.js'; + +describe('baseBackendConfigSchema', () => { + it('parses minimal valid env', () => { + const config = baseBackendConfigSchema.parse({ + JWT_SECRET: 'test-secret', + }); + expect(config.PORT).toBe(3000); + expect(config.HOST).toBe('0.0.0.0'); + expect(config.NODE_ENV).toBe('development'); + expect(config.DB_PROVIDER).toBe('cosmos'); + expect(config.COSMOS_DATABASE).toBe('lysnrai'); + expect(config.JWT_SECRET).toBe('test-secret'); + expect(config.PLATFORM_JWKS_URL).toBeUndefined(); + }); + + it('applies overrides', () => { + const config = baseBackendConfigSchema.parse({ + PORT: '4010', + NODE_ENV: 'production', + DB_PROVIDER: 'memory', + JWT_SECRET: 'prod-secret', + PLATFORM_JWKS_URL: 'https://example.com/.well-known/jwks.json', + }); + expect(config.PORT).toBe(4010); + expect(config.NODE_ENV).toBe('production'); + expect(config.DB_PROVIDER).toBe('memory'); + expect(config.PLATFORM_JWKS_URL).toBe('https://example.com/.well-known/jwks.json'); + }); + + it('rejects missing JWT_SECRET', () => { + expect(() => baseBackendConfigSchema.parse({})).toThrow(); + }); + + it('rejects invalid NODE_ENV', () => { + expect(() => baseBackendConfigSchema.parse({ JWT_SECRET: 's', NODE_ENV: 'staging' })).toThrow(); + }); + + it('rejects invalid DB_PROVIDER', () => { + expect(() => + baseBackendConfigSchema.parse({ JWT_SECRET: 's', DB_PROVIDER: 'postgres' }) + ).toThrow(); + }); +}); + +describe('baseBackendConfigSchema.extend()', () => { + const extendedSchema = baseBackendConfigSchema.extend({ + PLATFORM_SERVICE_URL: baseBackendConfigSchema.shape.HOST.default('http://localhost:4003'), + CUSTOM_FLAG: baseBackendConfigSchema.shape.NODE_ENV.optional(), + }); + + it('parses extended config with product-specific fields', () => { + const config = extendedSchema.parse({ + JWT_SECRET: 'test-secret', + PORT: '4018', + SERVICE_NAME: 'actiontrail-backend', + }); + expect(config.PORT).toBe(4018); + expect(config.SERVICE_NAME).toBe('actiontrail-backend'); + expect(config.PLATFORM_SERVICE_URL).toBe('http://localhost:4003'); + }); +}); + +describe('parseBackendConfig', () => { + it('parses from explicit env object', () => { + const config = parseBackendConfig(baseBackendConfigSchema, { + JWT_SECRET: 'from-env', + PORT: '9999', + }); + expect(config.JWT_SECRET).toBe('from-env'); + expect(config.PORT).toBe(9999); + }); + + it('works with extended schemas', () => { + const schema = baseBackendConfigSchema.extend({ + WEBHOOK_SECRET: baseBackendConfigSchema.shape.HOST.default('dev-webhook'), + }); + const config = parseBackendConfig(schema, { JWT_SECRET: 'x' }); + expect(config.WEBHOOK_SECRET).toBe('dev-webhook'); + }); +}); diff --git a/vendor/bytelyst/backend-config/src/index.ts b/vendor/bytelyst/backend-config/src/index.ts new file mode 100644 index 0000000..ae8011c --- /dev/null +++ b/vendor/bytelyst/backend-config/src/index.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +/** + * Base Zod schema shared by all product backends. + * + * Products extend this with `.extend({...})` to add product-specific fields. + * The base covers: server, CORS, Cosmos DB, JWT auth, DB provider. + */ +export const baseBackendConfigSchema = z.object({ + PORT: z.coerce.number().default(3000), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default('backend'), + + DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), + COSMOS_ENDPOINT: z.string().default(''), + COSMOS_KEY: z.string().default(''), + COSMOS_DATABASE: z.string().default('lysnrai'), + + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + PLATFORM_JWKS_URL: z.string().url().optional(), +}); + +export type BaseBackendConfig = z.infer; + +/** + * Parse and validate backend config from process.env. + * + * @param schema — Zod object schema (typically `baseBackendConfigSchema.extend({...})`) + * @param env — environment object (defaults to process.env) + * @returns Validated, typed config + */ +export function parseBackendConfig( + schema: z.ZodObject, + env: Record = process.env as Record +): z.infer> { + return schema.parse(env); +} diff --git a/vendor/bytelyst/backend-config/tsconfig.json b/vendor/bytelyst/backend-config/tsconfig.json new file mode 100644 index 0000000..01c4d9a --- /dev/null +++ b/vendor/bytelyst/backend-config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/backend-flags/package.json b/vendor/bytelyst/backend-flags/package.json new file mode 100644 index 0000000..96f1aff --- /dev/null +++ b/vendor/bytelyst/backend-flags/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bytelyst/backend-flags", + "version": "0.1.5", + "description": "In-memory feature flag registry for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run --pool forks", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/backend-flags/src/index.test.ts b/vendor/bytelyst/backend-flags/src/index.test.ts new file mode 100644 index 0000000..fa5aba5 --- /dev/null +++ b/vendor/bytelyst/backend-flags/src/index.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { createFlagRegistry } from './index.js'; + +describe('createFlagRegistry', () => { + it('returns default flag values', () => { + const registry = createFlagRegistry({ + defaults: { 'feature.a': true, 'feature.b': false }, + }); + expect(registry.isFeatureEnabled('feature.a')).toBe(true); + expect(registry.isFeatureEnabled('feature.b')).toBe(false); + }); + + it('returns false for unknown flags', () => { + const registry = createFlagRegistry({ defaults: {} }); + expect(registry.isFeatureEnabled('nonexistent')).toBe(false); + }); + + it('getAllFlags returns all defaults', () => { + const registry = createFlagRegistry({ + defaults: { a: true, b: false, c: true }, + }); + expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true }); + }); + + it('setFlag overrides a value', () => { + const registry = createFlagRegistry({ defaults: { x: false } }); + expect(registry.isFeatureEnabled('x')).toBe(false); + registry.setFlag('x', true); + expect(registry.isFeatureEnabled('x')).toBe(true); + }); + + it('setFlag creates new flags', () => { + const registry = createFlagRegistry({ defaults: {} }); + registry.setFlag('new.flag', true); + expect(registry.isFeatureEnabled('new.flag')).toBe(true); + expect(registry.getAllFlags()).toEqual({ 'new.flag': true }); + }); + + it('accepts userId parameter without error', () => { + const registry = createFlagRegistry({ defaults: { a: true } }); + expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true); + }); +}); diff --git a/vendor/bytelyst/backend-flags/src/index.ts b/vendor/bytelyst/backend-flags/src/index.ts new file mode 100644 index 0000000..30ad476 --- /dev/null +++ b/vendor/bytelyst/backend-flags/src/index.ts @@ -0,0 +1,38 @@ +/** + * In-memory feature flag registry for product backends. + * + * Products call createFlagRegistry() with their default flags, + * then use isFeatureEnabled/getAllFlags/setFlag as needed. + */ + +export interface FlagRegistry { + isFeatureEnabled(flag: string, userId?: string): boolean; + getAllFlags(): Record; + setFlag(flag: string, value: boolean): void; +} + +export interface FlagRegistryOptions { + /** Default flag values. */ + defaults: Record; + /** Master switch — when false, flags are still resolved from defaults but + * the registry won't attempt remote/dynamic flag resolution (future use). */ + enabled?: boolean; +} + +export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry { + const flags: Map = new Map(Object.entries(opts.defaults)); + + return { + isFeatureEnabled(flag: string, _userId?: string): boolean { + return flags.get(flag) ?? false; + }, + + getAllFlags(): Record { + return Object.fromEntries(flags); + }, + + setFlag(flag: string, value: boolean): void { + flags.set(flag, value); + }, + }; +} diff --git a/vendor/bytelyst/backend-flags/tsconfig.json b/vendor/bytelyst/backend-flags/tsconfig.json new file mode 100644 index 0000000..01c4d9a --- /dev/null +++ b/vendor/bytelyst/backend-flags/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/backend-telemetry/package.json b/vendor/bytelyst/backend-telemetry/package.json new file mode 100644 index 0000000..64e2400 --- /dev/null +++ b/vendor/bytelyst/backend-telemetry/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bytelyst/backend-telemetry", + "version": "0.1.5", + "description": "In-memory telemetry event buffer for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run --pool forks", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/backend-telemetry/src/index.test.ts b/vendor/bytelyst/backend-telemetry/src/index.test.ts new file mode 100644 index 0000000..ad15d9e --- /dev/null +++ b/vendor/bytelyst/backend-telemetry/src/index.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { createTelemetryBuffer } from './index.js'; + +describe('createTelemetryBuffer', () => { + it('buffers events when enabled', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('test.event', 'user-1', { key: 'val' }); + const events = buf.getBufferedEvents(); + expect(events).toHaveLength(1); + expect(events[0].event).toBe('test.event'); + expect(events[0].userId).toBe('user-1'); + expect(events[0].properties).toEqual({ key: 'val' }); + expect(events[0].timestamp).toBeDefined(); + }); + + it('is a no-op when disabled', () => { + const buf = createTelemetryBuffer({ enabled: false }); + buf.trackEvent('test.event', 'user-1'); + expect(buf.getBufferedEvents()).toHaveLength(0); + }); + + it('flushEvents returns and clears buffer', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('a'); + buf.trackEvent('b'); + const flushed = buf.flushEvents(); + expect(flushed).toHaveLength(2); + expect(buf.getBufferedEvents()).toHaveLength(0); + }); + + it('getBufferedEvents returns a copy', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('a'); + const copy = buf.getBufferedEvents(); + copy.push({ event: 'fake' }); + expect(buf.getBufferedEvents()).toHaveLength(1); + }); + + it('handles missing optional fields', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('minimal'); + const events = buf.getBufferedEvents(); + expect(events[0].userId).toBeUndefined(); + expect(events[0].properties).toBeUndefined(); + }); +}); diff --git a/vendor/bytelyst/backend-telemetry/src/index.ts b/vendor/bytelyst/backend-telemetry/src/index.ts new file mode 100644 index 0000000..ffd9cfa --- /dev/null +++ b/vendor/bytelyst/backend-telemetry/src/index.ts @@ -0,0 +1,50 @@ +/** + * In-memory telemetry event buffer for product backends. + * + * Products call createTelemetryBuffer() with an enabled flag, + * then use trackEvent/getBufferedEvents/flushEvents as needed. + */ + +export interface TelemetryEvent { + event: string; + userId?: string; + properties?: Record; + timestamp?: string; +} + +export interface TelemetryBuffer { + trackEvent(event: string, userId?: string, properties?: Record): void; + getBufferedEvents(): TelemetryEvent[]; + flushEvents(): TelemetryEvent[]; +} + +export interface TelemetryBufferOptions { + /** Master switch — when false, trackEvent is a no-op. */ + enabled: boolean; +} + +export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer { + const buffer: TelemetryEvent[] = []; + + return { + trackEvent(event: string, userId?: string, properties?: Record): void { + if (!opts.enabled) return; + buffer.push({ + event, + userId, + properties, + timestamp: new Date().toISOString(), + }); + }, + + getBufferedEvents(): TelemetryEvent[] { + return [...buffer]; + }, + + flushEvents(): TelemetryEvent[] { + const flushed = [...buffer]; + buffer.length = 0; + return flushed; + }, + }; +} diff --git a/vendor/bytelyst/backend-telemetry/tsconfig.json b/vendor/bytelyst/backend-telemetry/tsconfig.json new file mode 100644 index 0000000..01c4d9a --- /dev/null +++ b/vendor/bytelyst/backend-telemetry/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/billing-client/.gitignore b/vendor/bytelyst/billing-client/.gitignore new file mode 100644 index 0000000..aa1ec1e --- /dev/null +++ b/vendor/bytelyst/billing-client/.gitignore @@ -0,0 +1 @@ +*.tgz diff --git a/vendor/bytelyst/billing-client/package.json b/vendor/bytelyst/billing-client/package.json new file mode 100644 index 0000000..7b872b6 --- /dev/null +++ b/vendor/bytelyst/billing-client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bytelyst/billing-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage", + "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 --pool forks" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/billing-client/src/index.test.ts b/vendor/bytelyst/billing-client/src/index.test.ts new file mode 100644 index 0000000..8a7c05b --- /dev/null +++ b/vendor/bytelyst/billing-client/src/index.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createBillingClient, BillingApiError } from './index.js'; +import type { BillingClient } from './index.js'; + +function mockFetch(status: number, body: unknown) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); +} + +describe('createBillingClient', () => { + let client: BillingClient; + const config = { + baseUrl: 'http://localhost:4003/api', + productId: 'notelett', + getAccessToken: () => 'test-token', + }; + + beforeEach(() => { + client = createBillingClient(config); + }); + + it('listPlans — returns plans array', async () => { + const plans = [{ name: 'free', displayName: 'Free', price: 0 }]; + globalThis.fetch = mockFetch(200, { plans }); + const result = await client.listPlans(); + expect(result).toEqual(plans); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:4003/api/plans', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('getPlan — returns single plan', async () => { + const plan = { name: 'pro', displayName: 'Pro', price: 9.99 }; + globalThis.fetch = mockFetch(200, plan); + const result = await client.getPlan('pro'); + expect(result).toEqual(plan); + }); + + it('getSubscription — returns subscription', async () => { + const sub = { id: 'sub_1', plan: 'free', status: 'active' }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.getSubscription(); + expect(result).toEqual(sub); + }); + + it('getSubscription — returns null on 404', async () => { + globalThis.fetch = mockFetch(404, { message: 'Not found' }); + const result = await client.getSubscription(); + expect(result).toBeNull(); + }); + + it('changePlan — sends plan in body', async () => { + const sub = { id: 'sub_1', plan: 'pro', status: 'active' }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.changePlan('pro'); + expect(result.plan).toBe('pro'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:4003/api/subscriptions/me/change-plan', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ plan: 'pro' }), + }) + ); + }); + + it('cancelSubscription — POST to cancel endpoint', async () => { + const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.cancelSubscription(); + expect(result.cancelAtPeriodEnd).toBe(true); + }); + + it('resumeSubscription — POST to resume endpoint', async () => { + const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.resumeSubscription(); + expect(result.cancelAtPeriodEnd).toBe(false); + }); + + it('listPayments — returns payments array', async () => { + const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }]; + globalThis.fetch = mockFetch(200, { payments }); + const result = await client.listPayments(); + expect(result).toEqual(payments); + }); + + it('getUsage — returns usage summary', async () => { + const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 }; + globalThis.fetch = mockFetch(200, usage); + const result = await client.getUsage(); + expect(result.tokensRemaining).toBe(9500); + }); + + it('sends auth header and product-id header', async () => { + globalThis.fetch = mockFetch(200, { plans: [] }); + await client.listPlans(); + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + const headers = call[1].headers; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['x-product-id']).toBe('notelett'); + }); + + it('throws BillingApiError on non-ok response', async () => { + globalThis.fetch = mockFetch(403, { message: 'Forbidden' }); + await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError); + }); + + it('works without access token', async () => { + const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null }); + globalThis.fetch = mockFetch(200, { plans: [] }); + await noAuthClient.listPlans(); + const headers = (globalThis.fetch as ReturnType).mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + }); +}); diff --git a/vendor/bytelyst/billing-client/src/index.ts b/vendor/bytelyst/billing-client/src/index.ts new file mode 100644 index 0000000..44db5d8 --- /dev/null +++ b/vendor/bytelyst/billing-client/src/index.ts @@ -0,0 +1,214 @@ +/** + * @bytelyst/billing-client — Browser/React Native-safe billing client + * + * Wraps platform-service /plans, /subscriptions, /payments, /usage + * endpoints with typed methods. Any ByteLyst product can add billing + * with minimal wiring: + * + * @example + * ```ts + * import { createBillingClient } from '@bytelyst/billing-client'; + * + * const billing = createBillingClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'notelett', + * getAccessToken: () => localStorage.getItem('notelett_access_token'), + * }); + * + * const plans = await billing.listPlans(); + * const sub = await billing.getSubscription(); + * await billing.changePlan('pro'); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export type PlanTier = 'free' | 'pro' | 'enterprise'; +export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing'; +export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded'; + +export interface PlanConfig { + id: string; + productId: string; + name: string; + displayName: string; + price: number; + tokens: number; + words: number; + dictations: number; + features: string[]; + stripePriceId?: string; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Subscription { + id: string; + productId: string; + userId: string; + plan: PlanTier; + status: SubscriptionStatus; + currentPeriodStart: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + monthlyPrice: number; + tokensIncluded: number; + tokensUsed: number; + stripeCustomerId?: string; + stripeSubscriptionId?: string; + createdAt: string; + updatedAt: string; +} + +export interface Payment { + id: string; + productId: string; + userId: string; + amount: number; + currency: string; + status: PaymentStatus; + description: string; + method: string; + invoiceUrl?: string; + createdAt: string; +} + +export interface UsageSummary { + tokensUsed: number; + tokensIncluded: number; + tokensRemaining: number; + percentUsed: number; + currentPeriodStart: string; + currentPeriodEnd: string; +} + +// ── Config ─────────────────────────────────────────────────── + +export interface BillingClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + /** Product identifier. */ + productId: string; + /** Returns current access token, or null if not authenticated. */ + getAccessToken: () => string | null; + /** Request timeout in ms (default: 15000). */ + timeoutMs?: number; +} + +// ── Client Interface ───────────────────────────────────────── + +export interface BillingClient { + /** List available plans for the product. */ + listPlans(): Promise; + /** Get a specific plan by name. */ + getPlan(planName: string): Promise; + /** Get current user's subscription. Returns null if no subscription. */ + getSubscription(): Promise; + /** Change to a different plan (creates or updates subscription). */ + changePlan(plan: PlanTier): Promise; + /** Cancel subscription at period end. */ + cancelSubscription(): Promise; + /** Resume a cancelled subscription (undo cancel-at-period-end). */ + resumeSubscription(): Promise; + /** List payment history. */ + listPayments(): Promise; + /** Get usage summary for current billing period. */ + getUsage(): Promise; +} + +// ── Errors ─────────────────────────────────────────────────── + +export class BillingApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message?: string + ) { + super(message ?? `Billing API error ${status}`); + this.name = 'BillingApiError'; + } +} + +// ── Factory ────────────────────────────────────────────────── + +export function createBillingClient(config: BillingClientConfig): BillingClient { + const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config; + + async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + }; + + 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 != null ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (res.status === 204) return undefined as T; + if (res.status === 404) return null as T; + + const json = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new BillingApiError( + res.status, + json, + (json as Record).message ?? `HTTP ${res.status}` + ); + } + + return json as T; + } finally { + clearTimeout(timer); + } + } + + return { + async listPlans() { + const result = await request<{ plans: PlanConfig[] }>('GET', '/plans'); + return result.plans; + }, + + async getPlan(planName: string) { + return request('GET', `/plans/${encodeURIComponent(planName)}`); + }, + + async getSubscription() { + // Platform-service expects userId in path; the userId comes from the JWT. + // We use a convenience endpoint that reads userId from the token. + return request('GET', '/subscriptions/me'); + }, + + async changePlan(plan: PlanTier) { + return request('POST', '/subscriptions/me/change-plan', { plan }); + }, + + async cancelSubscription() { + return request('POST', '/subscriptions/me/cancel'); + }, + + async resumeSubscription() { + return request('POST', '/subscriptions/me/resume'); + }, + + async listPayments() { + const result = await request<{ payments: Payment[] }>('GET', '/payments/me'); + return result.payments; + }, + + async getUsage() { + return request('GET', '/subscriptions/me/usage'); + }, + }; +} diff --git a/vendor/bytelyst/billing-client/tsconfig.json b/vendor/bytelyst/billing-client/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/vendor/bytelyst/billing-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/blob-client/package.json b/vendor/bytelyst/blob-client/package.json new file mode 100644 index 0000000..82f8bcd --- /dev/null +++ b/vendor/bytelyst/blob-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/blob-client", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe blob storage client — SAS URL upload/download via 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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/blob-client/src/index.test.ts b/vendor/bytelyst/blob-client/src/index.test.ts new file mode 100644 index 0000000..8ddad34 --- /dev/null +++ b/vendor/bytelyst/blob-client/src/index.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createBlobClient, BlobApiError, BlobUploadError } from './index.js'; + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + headers: new Headers(), + } as unknown as Response; +} + +function blobClient(overrides?: Partial[0]>) { + return createBlobClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', + ...overrides, + }); +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('createBlobClient', () => { + describe('getSasUrl', () => { + it('requests a SAS URL from platform-service', async () => { + const sasResponse = { + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'r', + expiresInMinutes: 60, + expiresAt: '2026-03-02T00:00:00.000Z', + }; + mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse)); + + const client = blobClient(); + const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg'); + + expect(result).toEqual(sasResponse); + expect(mockFetch).toHaveBeenCalledOnce(); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/blob/sas'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'r', + expiresInMinutes: 60, + }); + }); + + it('sends auth and product headers', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); + + const client = blobClient(); + await client.getSasUrl('attachments', 'blob.jpg'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['x-product-id']).toBe('testapp'); + expect(headers['x-request-id']).toBeDefined(); + }); + + it('omits auth header when no token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); + + const client = blobClient({ getAccessToken: () => null }); + await client.getSasUrl('attachments', 'blob.jpg'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('throws BlobApiError on non-ok response', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401)); + + const client = blobClient(); + await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError); + }); + }); + + describe('upload', () => { + it('requests SAS then uploads directly to Azure', async () => { + const sasResponse = { + sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc', + container: 'attachments', + blobName: 'testapp/user1/photo.jpg', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }; + mockFetch + .mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request + .mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload + + const client = blobClient(); + const result = await client.upload('attachments', 'file-data', { + contentType: 'image/jpeg', + blobName: 'testapp/user1/photo.jpg', + }); + + expect(result.container).toBe('attachments'); + expect(result.blobName).toBe('testapp/user1/photo.jpg'); + expect(result.sasUrl).toBe( + 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg' + ); + + // Verify Azure upload call + const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]; + expect(uploadUrl).toBe(sasResponse.sasUrl); + expect(uploadInit.method).toBe('PUT'); + expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob'); + expect(uploadInit.headers['Content-Type']).toBe('image/jpeg'); + expect(uploadInit.body).toBe('file-data'); + }); + + it('auto-generates blobName when not provided', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x', + container: 'attachments', + blobName: 'testapp/123-abc', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: true, status: 201 } as Response); + + const client = blobClient(); + const result = await client.upload('attachments', 'data', { + contentType: 'text/plain', + }); + + // Verify the SAS request included a generated blobName starting with productId + const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(sasBody.blobName).toMatch(/^testapp\//); + expect(result.container).toBe('attachments'); + }); + + it('throws BlobUploadError when Azure returns non-ok', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', + container: 'attachments', + blobName: 'blob', + permissions: 'w', + expiresInMinutes: 30, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: false, status: 403 } as Response); + + const client = blobClient(); + await expect( + client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' }) + ).rejects.toThrow(BlobUploadError); + }); + }); + + describe('download', () => { + it('requests read SAS then fetches the blob', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', + container: 'attachments', + blobName: 'blob', + permissions: 'r', + expiresInMinutes: 15, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ + ok: true, + status: 200, + blob: () => Promise.resolve(new Blob()), + } as unknown as Response); + + const client = blobClient(); + const res = await client.download('attachments', 'blob'); + + expect(res.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Second call should be to the SAS URL + expect(mockFetch.mock.calls[1][0]).toBe( + 'https://storage.blob.core.windows.net/attachments/blob?sig=read' + ); + }); + + it('throws BlobApiError on download failure', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', + container: 'attachments', + blobName: 'blob', + permissions: 'r', + expiresInMinutes: 15, + expiresAt: '2026-03-02T00:00:00.000Z', + }) + ) + .mockResolvedValueOnce({ ok: false, status: 404 } as Response); + + const client = blobClient(); + await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError); + }); + }); + + describe('list', () => { + it('lists blobs with prefix and limit', async () => { + const listResponse = { + blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }], + count: 1, + container: 'attachments', + prefix: 'testapp/user1/', + }; + mockFetch.mockResolvedValueOnce(jsonResponse(listResponse)); + + const client = blobClient(); + const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 }); + + expect(result).toEqual(listResponse); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('container=attachments'); + expect(url).toContain('prefix=testapp%2Fuser1%2F'); + expect(url).toContain('limit=10'); + }); + + it('works without options', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null }) + ); + + const client = blobClient(); + await client.list('audio'); + + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('container=audio'); + expect(url).not.toContain('prefix='); + expect(url).not.toContain('limit='); + }); + }); + + describe('info', () => { + it('fetches blob metadata', async () => { + const infoResponse = { + name: 'testapp/user1/photo.jpg', + container: 'attachments', + contentType: 'image/jpeg', + size: 2048, + lastModified: '2026-03-01T00:00:00.000Z', + url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg', + metadata: {}, + }; + mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse)); + + const client = blobClient(); + const result = await client.info('attachments', 'testapp/user1/photo.jpg'); + + expect(result).toEqual(infoResponse); + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg'); + }); + }); +}); diff --git a/vendor/bytelyst/blob-client/src/index.ts b/vendor/bytelyst/blob-client/src/index.ts new file mode 100644 index 0000000..f0bf9a9 --- /dev/null +++ b/vendor/bytelyst/blob-client/src/index.ts @@ -0,0 +1,289 @@ +/** + * Browser/React Native-safe blob storage client. + * + * Wraps the platform-service blob endpoints to provide: + * - SAS URL generation for direct upload/download + * - Direct blob upload via SAS URL (PUT with raw body) + * - Direct blob download via SAS URL + * - Blob listing and metadata + * + * Requires a fetch-compatible environment (browser, React Native, Node 18+). + * + * @example + * ```ts + * import { createBlobClient } from '@bytelyst/blob-client'; + * + * const blob = createBlobClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * getAccessToken: () => authClient.getAccessToken(), + * }); + * + * // Upload a file + * const { url } = await blob.upload('attachments', file, { + * contentType: 'image/jpeg', + * blobName: 'nomgap/user123/photos/meal.jpg', + * }); + * + * // Download a file + * const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg'); + * + * // List blobs + * const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' }); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export interface BlobClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Function that returns the current access token, or null. */ + getAccessToken: () => string | null; + + /** Request timeout in milliseconds for API calls. Default: 15000. */ + timeoutMs?: number; + + /** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */ + uploadTimeoutMs?: number; +} + +export interface SasUrlResponse { + sasUrl: string; + container: string; + blobName: string; + permissions: string; + expiresInMinutes: number; + expiresAt: string; +} + +export interface BlobInfo { + name: string; + container: string; + contentType?: string; + size: number; + lastModified?: string; + url: string; + metadata: Record; +} + +export interface ListBlobsResponse { + blobs: BlobInfo[]; + count: number; + container: string; + prefix: string | null; +} + +export interface UploadOptions { + /** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */ + contentType: string; + + /** Full blob path. If omitted, auto-generated as `//-`. */ + blobName?: string; + + /** SAS token expiry in minutes. Default: 30. */ + expiresInMinutes?: number; +} + +export interface UploadResult { + sasUrl: string; + container: string; + blobName: string; +} + +export interface BlobClient { + /** Get a SAS URL for direct upload or download. */ + getSasUrl( + container: string, + blobName: string, + permissions?: 'r' | 'w' | 'rw' | 'rwc', + expiresInMinutes?: number + ): Promise; + + /** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */ + upload( + container: string, + data: Blob | ArrayBuffer | Uint8Array | string, + options: UploadOptions + ): Promise; + + /** Download a blob via SAS URL. Returns the Response for streaming. */ + download(container: string, blobName: string): Promise; + + /** List blobs in a container. */ + list( + container: string, + options?: { prefix?: string; limit?: number } + ): Promise; + + /** Get blob metadata/info. */ + info(container: string, blobName: string): Promise; +} + +// ── Errors ─────────────────────────────────────────────────── + +export class BlobApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message?: string + ) { + super(message ?? `Blob API error ${status}`); + this.name = 'BlobApiError'; + } +} + +export class BlobUploadError extends Error { + constructor( + public readonly status: number, + message?: string + ) { + super(message ?? `Blob upload failed with status ${status}`); + this.name = 'BlobUploadError'; + } +} + +// ── UUID helper ────────────────────────────────────────────── + +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 createBlobClient(config: BlobClientConfig): BlobClient { + const { + baseUrl, + productId, + getAccessToken, + timeoutMs = 15_000, + uploadTimeoutMs = 120_000, + } = config; + + function authHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': uuid(), + }; + const token = getAccessToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + return headers; + } + + async function apiRequest(method: string, path: string, body?: unknown): Promise { + const url = `${baseUrl}${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await globalThis.fetch(url, { + method, + headers: authHeaders(), + body: body != null ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + const json = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new BlobApiError( + res.status, + json, + (json as Record).message ?? `HTTP ${res.status}` + ); + } + + return json as T; + } finally { + clearTimeout(timer); + } + } + + async function getSasUrl( + container: string, + blobName: string, + permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r', + expiresInMinutes = 60 + ): Promise { + return apiRequest('POST', '/blob/sas', { + container, + blobName, + permissions, + expiresInMinutes, + }); + } + + async function upload( + container: string, + data: Blob | ArrayBuffer | Uint8Array | string, + options: UploadOptions + ): Promise { + const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`; + + const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), uploadTimeoutMs); + + try { + const res = await globalThis.fetch(sas.sasUrl, { + method: 'PUT', + headers: { + 'x-ms-blob-type': 'BlockBlob', + 'Content-Type': options.contentType, + }, + body: data as BodyInit, + signal: controller.signal, + }); + + if (!res.ok) { + throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`); + } + + return { sasUrl: sas.sasUrl.split('?')[0], container, blobName }; + } finally { + clearTimeout(timer); + } + } + + async function download(container: string, blobName: string): Promise { + const sas = await getSasUrl(container, blobName, 'r', 15); + + const res = await globalThis.fetch(sas.sasUrl); + if (!res.ok) { + throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`); + } + return res; + } + + async function list( + container: string, + options?: { prefix?: string; limit?: number } + ): Promise { + const params = new URLSearchParams({ container }); + if (options?.prefix) params.set('prefix', options.prefix); + if (options?.limit) params.set('limit', String(options.limit)); + + return apiRequest('GET', `/blob/list?${params.toString()}`); + } + + async function info(container: string, blobName: string): Promise { + return apiRequest( + 'GET', + `/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}` + ); + } + + return { getSasUrl, upload, download, list, info }; +} diff --git a/vendor/bytelyst/blob-client/tsconfig.json b/vendor/bytelyst/blob-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/blob-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/vendor/bytelyst/blob/package.json b/vendor/bytelyst/blob/package.json new file mode 100644 index 0000000..8df2583 --- /dev/null +++ b/vendor/bytelyst/blob/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/blob", + "version": "0.2.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "pretest": "pnpm --dir ../.. --filter @bytelyst/storage build", + "build": "tsc", + "test": "vitest run --pool forks" + }, + "dependencies": { + "@bytelyst/storage": "workspace:*" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/blob/src/__tests__/blob.test.ts b/vendor/bytelyst/blob/src/__tests__/blob.test.ts new file mode 100644 index 0000000..c735c96 --- /dev/null +++ b/vendor/bytelyst/blob/src/__tests__/blob.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setStorage, MemoryStorageProvider } from '@bytelyst/storage'; + +import { + _resetBlobClient, + generateSasUrl, + getBucket, + getStorageProvider, + isBlobStorageConfigured, + BLOB_CONTAINERS, +} from '../index.js'; + +describe('blob', () => { + beforeEach(() => { + _resetBlobClient(); + delete process.env.AZURE_BLOB_CONNECTION_STRING; + delete process.env.AZURE_BLOB_ACCOUNT_NAME; + delete process.env.AZURE_BLOB_ACCOUNT_KEY; + delete process.env.STORAGE_PROVIDER; + }); + + afterEach(() => { + _resetBlobClient(); + delete process.env.AZURE_BLOB_CONNECTION_STRING; + delete process.env.AZURE_BLOB_ACCOUNT_NAME; + delete process.env.AZURE_BLOB_ACCOUNT_KEY; + delete process.env.STORAGE_PROVIDER; + }); + + describe('isBlobStorageConfigured', () => { + it('is false when unset', () => { + expect(isBlobStorageConfigured()).toBe(false); + }); + + it('is true when connection string is set', () => { + process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;'; + expect(isBlobStorageConfigured()).toBe(true); + }); + + it('is true when account name + key are set', () => { + process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc'; + process.env.AZURE_BLOB_ACCOUNT_KEY = 'key=='; + expect(isBlobStorageConfigured()).toBe(true); + }); + + it('is true when provider is memory', () => { + process.env.STORAGE_PROVIDER = 'memory'; + expect(isBlobStorageConfigured()).toBe(true); + }); + }); + + describe('with memory provider', () => { + let memoryProvider: MemoryStorageProvider; + + beforeEach(() => { + memoryProvider = new MemoryStorageProvider(); + setStorage(memoryProvider); + }); + + it('getStorageProvider returns the provider', async () => { + const provider = await getStorageProvider(); + expect(provider).toBe(memoryProvider); + }); + + it('getBucket returns a bucket by name', async () => { + const bucket = await getBucket('audio'); + expect(bucket).toBeDefined(); + // Upload and download to verify it works + await bucket.upload('test.wav', Buffer.from('hello')); + const data = await bucket.download('test.wav'); + expect(data.toString()).toBe('hello'); + }); + + it('generateSasUrl returns a signed URL', async () => { + const url = await generateSasUrl('audio', 'path/file.wav', 'r', 10); + expect(url).toContain('audio'); + expect(url).toContain('path/file.wav'); + expect(url).toContain('signed=true'); + }); + + it('generateSasUrl defaults to read permissions', async () => { + const url = await generateSasUrl('audio', 'file.wav'); + expect(url).toContain('signed=true'); + }); + + it('_resetBlobClient resets the storage singleton', async () => { + const p1 = await getStorageProvider(); + _resetBlobClient(); + // After reset, inject a new provider + const newProvider = new MemoryStorageProvider(); + setStorage(newProvider); + const p2 = await getStorageProvider(); + expect(p1).not.toBe(p2); + }); + }); + + describe('BLOB_CONTAINERS', () => { + it('has expected container names', () => { + expect(BLOB_CONTAINERS.audio).toBe('audio'); + expect(BLOB_CONTAINERS.transcripts).toBe('transcripts'); + expect(BLOB_CONTAINERS.attachments).toBe('attachments'); + expect(BLOB_CONTAINERS.avatars).toBe('avatars'); + expect(BLOB_CONTAINERS.releases).toBe('releases'); + expect(BLOB_CONTAINERS.backups).toBe('backups'); + expect(BLOB_CONTAINERS.feedbackScreenshots).toBe('feedback-screenshots'); + }); + }); +}); diff --git a/vendor/bytelyst/blob/src/blob.ts b/vendor/bytelyst/blob/src/blob.ts new file mode 100644 index 0000000..71e0d82 --- /dev/null +++ b/vendor/bytelyst/blob/src/blob.ts @@ -0,0 +1,94 @@ +/** + * Shared Blob Storage utilities. + * + * Delegates to @bytelyst/storage for provider-agnostic blob operations. + * Keeps the same exported API surface for backward compatibility. + * + * Expected env vars: + * STORAGE_PROVIDER — 'azure' (default) | 'memory' + * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure) + * — OR — + * AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY + */ + +import { + getStorage, + _resetStorage, + type StorageProvider, + type StorageBucket, +} from '@bytelyst/storage'; + +/** + * Known blob containers and their purposes. + * + * Note: This is a convenience list (not enforced). Products can add their own + * containers as needed. + */ +export const BLOB_CONTAINERS = { + audio: 'audio', // Dictation audio recordings + transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT) + attachments: 'attachments', // Tracker item attachments (screenshots, docs) + avatars: 'avatars', // User profile images + releases: 'releases', // Desktop app update binaries + backups: 'backups', // Cosmos DB JSON backups + feedbackScreenshots: 'feedback-screenshots', // User feedback screenshot attachments +} as const; + +export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; + +/** + * Get the storage provider singleton. + */ +export async function getStorageProvider(): Promise { + return getStorage(); +} + +/** + * Get a bucket (container) by name. + */ +export async function getBucket(containerName: string): Promise { + const storage = await getStorage(); + return storage.getBucket(containerName); +} + +/** + * Generate a signed URL for direct browser upload (or download). + * + * @param containerName - Target container + * @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav") + * @param permissions - SAS permissions (default: read) + * @param expiresInMinutes - Token lifetime (default: 60) + * @returns Full signed URL for the blob + */ +export async function generateSasUrl( + containerName: string, + blobName: string, + permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r', + expiresInMinutes = 60 +): Promise { + const bucket = await getBucket(containerName); + const perm = permissions.includes('w') ? ('write' as const) : ('read' as const); + return bucket.getSignedUrl(blobName, { + permissions: perm, + expiresIn: expiresInMinutes * 60, + }); +} + +/** + * Check if blob storage is configured. + */ +export function isBlobStorageConfigured(): boolean { + const provider = process.env.STORAGE_PROVIDER || 'azure'; + if (provider === 'memory') return true; + return !!( + process.env.AZURE_BLOB_CONNECTION_STRING || + (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) + ); +} + +/** + * Test helper: reset module singletons/caches. + */ +export function _resetBlobClient(): void { + _resetStorage(); +} diff --git a/vendor/bytelyst/blob/src/index.ts b/vendor/bytelyst/blob/src/index.ts new file mode 100644 index 0000000..10b6d7c --- /dev/null +++ b/vendor/bytelyst/blob/src/index.ts @@ -0,0 +1 @@ +export * from './blob.js'; diff --git a/vendor/bytelyst/blob/tsconfig.json b/vendor/bytelyst/blob/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/blob/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/broadcast-client/README.md b/vendor/bytelyst/broadcast-client/README.md new file mode 100644 index 0000000..3227c51 --- /dev/null +++ b/vendor/bytelyst/broadcast-client/README.md @@ -0,0 +1,227 @@ +# @bytelyst/broadcast-client + +TypeScript client for the ByteLyst Broadcast & Messaging platform. Provides in-app message polling, read receipts, and push notification token management. + +## Installation + +```bash +npm install @bytelyst/broadcast-client +# or +pnpm add @bytelyst/broadcast-client +``` + +## Quick Start + +```typescript +import { createBroadcastClient } from '@bytelyst/broadcast-client'; + +const client = createBroadcastClient({ + baseURL: 'https://api.bytelyst.io/v1', + productId: 'lysnrai', + getAuthToken: async () => { + // Return your JWT token + return localStorage.getItem('token'); + } +}); + +// Start polling for messages (every 60 seconds) +client.startPolling(60000, (messages) => { + console.log('New messages:', messages); +}); +``` + +## API Reference + +### `createBroadcastClient(config)` + +Creates a new broadcast client instance. + +**Config:** +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `baseURL` | string | Yes | API base URL | +| `productId` | string | Yes | Product identifier | +| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | + +### Methods + +#### `getMessages()` +Fetch active in-app messages for the current user. + +```typescript +const { data, error } = await client.getMessages(); +// Returns: { messages: InAppMessage[] } +``` + +#### `markRead(messageId: string)` +Mark a message as read. + +```typescript +await client.markRead('msg_123'); +``` + +#### `markDismissed(messageId: string)` +Dismiss a message. + +```typescript +await client.markDismissed('msg_123'); +``` + +#### `trackClick(messageId: string)` +Track when user clicks/taps a message CTA. + +```typescript +await client.trackClick('msg_123'); +``` + +#### `startPolling(intervalMs: number, callback: (messages) => void)` +Start polling for new messages. + +```typescript +client.startPolling(60000, (messages) => { + // Called every 60 seconds with current messages +}); +``` + +#### `stopPolling()` +Stop message polling. + +```typescript +client.stopPolling(); +``` + +#### `registerDeviceToken(token: string, platform: 'ios' | 'android' | 'web')` +Register push notification device token. + +```typescript +await client.registerDeviceToken('fcm_token_xyz', 'android'); +``` + +#### `unregisterDeviceToken(token: string)` +Unregister device token (e.g., on logout). + +```typescript +await client.unregisterDeviceToken('fcm_token_xyz'); +``` + +## React Integration + +### Hook Usage + +```typescript +import { useBroadcastClient } from './hooks/useBroadcastClient'; + +function App() { + const { messages, unreadCount, markRead, markDismissed } = useBroadcastClient({ + pollingInterval: 60000 + }); + + return ( +
+ {messages.map(msg => ( + markDismissed(msg.id)} + onClick={() => markRead(msg.id)} + /> + ))} +
+ ); +} +``` + +### Provider Pattern + +```typescript +// BroadcastProvider.tsx +import { createContext, useContext, useEffect, useState } from 'react'; +import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client'; + +const BroadcastContext = createContext(null); + +export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) { + const [client] = useState(() => createBroadcastClient(config)); + const [messages, setMessages] = useState([]); + + useEffect(() => { + client.startPolling(60000, setMessages); + return () => client.stopPolling(); + }, [client]); + + const value = { + messages, + unreadCount: messages.filter(m => m.status === 'unread').length, + markRead: client.markRead.bind(client), + markDismissed: client.markDismissed.bind(client), + trackClick: client.trackClick.bind(client), + }; + + return ( + + {children} + + ); +} + +export const useBroadcast = () => { + const ctx = useContext(BroadcastContext); + if (!ctx) throw new Error('useBroadcast must be used within BroadcastProvider'); + return ctx; +}; +``` + +## Types + +```typescript +interface InAppMessage { + id: string; + broadcastId: string; + title: string; + body?: string; + style: 'banner' | 'modal' | 'fullscreen' | 'toast'; + priority: 'low' | 'normal' | 'high' | 'urgent'; + ctaText?: string; + ctaUrl?: string; + imageUrl?: string; + deepLink?: { + screen: string; + params: Record; + }; + status: 'unread' | 'read' | 'dismissed'; + createdAt: string; +} + +interface BroadcastConfig { + baseURL: string; + productId: string; + getAuthToken: () => Promise; +} +``` + +## Error Handling + +All methods return a result tuple `[data, error]`: + +```typescript +const [data, error] = await client.getMessages(); + +if (error) { + console.error('Failed to fetch messages:', error.message); + return; +} + +// Use data.messages +``` + +## Browser Support + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +## License + +MIT © ByteLyst diff --git a/vendor/bytelyst/broadcast-client/package.json b/vendor/bytelyst/broadcast-client/package.json new file mode 100644 index 0000000..202a9f8 --- /dev/null +++ b/vendor/bytelyst/broadcast-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/broadcast-client", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe broadcast messaging 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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/broadcast-client/src/deep-link.ts b/vendor/bytelyst/broadcast-client/src/deep-link.ts new file mode 100644 index 0000000..ee4ecc8 --- /dev/null +++ b/vendor/bytelyst/broadcast-client/src/deep-link.ts @@ -0,0 +1,165 @@ +/** + * Deep Link Router — TypeScript + * Handles routing from push notification deep links to app screens + */ + +export interface DeepLinkRoute { + screen: string; + params?: Record; +} + +export type DeepLinkHandler = (route: DeepLinkRoute) => void; + +/** + * Deep Link Router class + */ +export class DeepLinkRouter { + private handlers = new Map(); + private fallbackHandler?: DeepLinkHandler; + + /** + * Register a handler for a specific screen + */ + register(screen: string, handler: DeepLinkHandler): void { + this.handlers.set(screen, handler); + } + + /** + * Set a fallback handler for unregistered screens + */ + setFallback(handler: DeepLinkHandler): void { + this.fallbackHandler = handler; + } + + /** + * Parse a deep link URL and extract route + */ + parseDeepLink(url: string): DeepLinkRoute | null { + try { + const urlObj = new URL(url); + + // Handle app-specific URLs: myapp://screen/params + if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { + const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter( + Boolean + ); + const screen = routeParts[0] || 'home'; + const params: Record = {}; + + // Parse query params + urlObj.searchParams.forEach((value, key) => { + params[key] = value; + }); + + return { screen, params }; + } + + // Handle web URLs with deep link params + const deepLinkParam = urlObj.searchParams.get('dl'); + if (deepLinkParam) { + return this.parseDeepLink(deepLinkParam); + } + + // Handle path-based routing: /screen/params + const pathParts = urlObj.pathname.split('/').filter(Boolean); + if (pathParts.length > 0) { + const screen = pathParts[0]; + const params: Record = {}; + + urlObj.searchParams.forEach((value, key) => { + params[key] = value; + }); + + return { screen, params }; + } + + return null; + } catch { + return null; + } + } + + /** + * Handle a deep link route + */ + handle(route: DeepLinkRoute): boolean { + const handler = this.handlers.get(route.screen); + + if (handler) { + handler(route); + return true; + } + + if (this.fallbackHandler) { + this.fallbackHandler(route); + return true; + } + + // eslint-disable-next-line no-console -- Deep-link consumers need an opt-in diagnostic when a route is dropped. + console.warn(`[DeepLink] No handler for screen: ${route.screen}`); + return false; + } + + /** + * Process a deep link URL end-to-end + */ + process(url: string): boolean { + const route = this.parseDeepLink(url); + if (!route) { + // eslint-disable-next-line no-console -- Parse failures are intentionally surfaced to host apps during integration. + console.warn(`[DeepLink] Failed to parse: ${url}`); + return false; + } + return this.handle(route); + } +} + +/** + * Create a broadcast deep link URL + */ +export function createBroadcastDeepLink( + baseUrl: string, + screen: string, + params?: Record, + broadcastId?: string +): string { + const url = new URL(baseUrl); + url.pathname = `/${screen}`; + + if (params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + + if (broadcastId) { + url.searchParams.set('broadcastId', broadcastId); + } + + return url.toString(); +} + +/** + * Common deep link screens for broadcast/survey flows + */ +export const DeepLinkScreens = { + // Broadcasts + BROADCAST_DETAIL: 'broadcast', + ANNOUNCEMENTS: 'announcements', + + // Surveys + SURVEY: 'survey', + SURVEY_LIST: 'surveys', + + // Product-specific (examples) + SETTINGS: 'settings', + PROFILE: 'profile', + UPGRADE: 'upgrade', + SUPPORT: 'support', + + // Fallback + HOME: 'home', +} as const; + +// Singleton instance for app-wide use +export const deepLinkRouter = new DeepLinkRouter(); diff --git a/vendor/bytelyst/broadcast-client/src/index.test.ts b/vendor/bytelyst/broadcast-client/src/index.test.ts new file mode 100644 index 0000000..f4464dd --- /dev/null +++ b/vendor/bytelyst/broadcast-client/src/index.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DeepLinkRouter, + DeepLinkScreens, + createBroadcastClient, + createBroadcastDeepLink, + createUseBroadcast, +} from './index.js'; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('createBroadcastClient', () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('lists messages with default segment and auth headers', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] })); + + const client = createBroadcastClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAuthToken: () => 'token-123', + platform: 'web', + appVersion: '1.2.3', + osVersion: 'macOS 15', + }); + + const result = await client.listMessages(); + + expect(result).toEqual({ messages: [{ id: 'm1' }] }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/broadcasts', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token-123', + 'x-product-id': 'testapp', + 'x-platform': 'web', + 'x-app-version': '1.2.3', + 'x-os-version': 'macOS 15', + 'x-user-segments': 'free', + }), + }) + ); + }); + + it('supports async auth token resolution and optional headers', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] })); + + const client = createBroadcastClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAuthToken: async () => 'async-token', + platform: 'ios', + appVersion: '2.0.0', + osVersion: '18.0', + countryCode: 'US', + regionCode: 'CA', + userSegments: ['pro', 'beta'], + }); + + await client.listMessages(); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/broadcasts', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer async-token', + 'x-country-code': 'US', + 'x-region-code': 'CA', + 'x-user-segments': 'pro,beta', + }), + }) + ); + }); + + it('throws a descriptive error when the API fails', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('boom'), + }); + + const client = createBroadcastClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAuthToken: () => 'token-123', + platform: 'web', + appVersion: '1.2.3', + osVersion: 'macOS 15', + }); + + await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom'); + }); + + it('polls messages and returns a cleanup function', async () => { + vi.useFakeTimers(); + fetchMock.mockResolvedValue(jsonResponse({ messages: [] })); + + const client = createBroadcastClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAuthToken: () => 'token-123', + platform: 'web', + appVersion: '1.2.3', + osVersion: 'macOS 15', + }); + + const stopPolling = client.pollMessages(1000); + await vi.advanceTimersByTimeAsync(3000); + + expect(fetchMock).toHaveBeenCalledTimes(3); + + stopPolling(); + await vi.advanceTimersByTimeAsync(2000); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('returns the same client from createUseBroadcast', () => { + const client = createBroadcastClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAuthToken: () => 'token-123', + platform: 'web', + appVersion: '1.2.3', + osVersion: 'macOS 15', + }); + + const useBroadcast = createUseBroadcast(client); + + expect(useBroadcast()).toEqual({ client }); + }); +}); + +describe('DeepLinkRouter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('parses custom app deep links using the host as the screen', () => { + const router = new DeepLinkRouter(); + + expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({ + screen: 'broadcast', + params: { + broadcastId: 'b1', + variant: 'test', + }, + }); + }); + + it('parses nested deep links from web URLs', () => { + const router = new DeepLinkRouter(); + + expect( + router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1') + ).toEqual({ + screen: 'survey', + params: { id: 's1' }, + }); + }); + + it('dispatches to registered handlers and falls back when needed', () => { + const router = new DeepLinkRouter(); + const primaryHandler = vi.fn(); + const fallbackHandler = vi.fn(); + + router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler); + router.setFallback(fallbackHandler); + + expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe( + true + ); + expect(primaryHandler).toHaveBeenCalledWith({ + screen: DeepLinkScreens.BROADCAST_DETAIL, + params: { id: 'b1' }, + }); + + expect(router.handle({ screen: 'unknown' })).toBe(true); + expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' }); + }); + + it('returns false and warns when processing an invalid URL without a fallback', () => { + const router = new DeepLinkRouter(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(router.process('not-a-url')).toBe(false); + expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url'); + }); +}); + +describe('createBroadcastDeepLink', () => { + it('builds deep links with params and broadcastId', () => { + expect( + createBroadcastDeepLink( + 'https://app.bytelyst.dev', + DeepLinkScreens.ANNOUNCEMENTS, + { tab: 'latest' }, + 'b42' + ) + ).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42'); + }); +}); diff --git a/vendor/bytelyst/broadcast-client/src/index.ts b/vendor/bytelyst/broadcast-client/src/index.ts new file mode 100644 index 0000000..2824e2b --- /dev/null +++ b/vendor/bytelyst/broadcast-client/src/index.ts @@ -0,0 +1,185 @@ +/** + * Broadcast Client — Browser/React Native-safe broadcast messaging client + * @module @bytelyst/broadcast-client + */ + +// ============================================================================= +// Types +// ============================================================================= + +export interface Broadcast { + id: string; + productId: string; + title: string; + body: string; + bodyMarkdown?: string; + ctaText?: string; + ctaUrl?: string; + imageUrl?: string; + channels: ('push' | 'in_app' | 'email')[]; + status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused'; + scheduledAt?: string; + sentAt?: string; + variant?: 'control' | 'treatment'; + experimentId?: string; + parentBroadcastId?: string; + metrics: BroadcastMetrics; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +export interface BroadcastMetrics { + targetedCount: number; + sentCount: number; + deliveredCount: number; + openedCount: number; + clickedCount: number; + dismissedCount: number; + convertedCount: number; +} + +export interface InAppMessage { + id: string; + userId: string; + productId: string; + broadcastId: string; + title: string; + body: string; + bodyMarkdown?: string; + ctaText?: string; + ctaUrl?: string; + priority: 'low' | 'normal' | 'high' | 'urgent'; + style: 'banner' | 'modal' | 'toast' | 'fullscreen'; + dismissible: boolean; + expiresAt?: string; + status: 'unread' | 'read' | 'dismissed'; + createdAt: string; + updatedAt: string; +} + +export interface BroadcastClientConfig { + /** Platform service base URL */ + baseUrl: string; + /** Product ID */ + productId: string; + /** Auth token provider (async or sync) */ + getAuthToken: (() => string) | (() => Promise); + /** Platform identifier */ + platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; + /** App version */ + appVersion: string; + /** OS version */ + osVersion: string; + /** Optional country code */ + countryCode?: string; + /** Optional region code */ + regionCode?: string; + /** User segments (default: ['free']) */ + userSegments?: string[]; +} + +// ============================================================================= +// Client Factory +// ============================================================================= + +export interface BroadcastClient { + /** List active in-app messages for current user */ + listMessages(): Promise<{ messages: InAppMessage[] }>; + /** Mark message as read */ + markRead(messageId: string): Promise; + /** Mark message as dismissed */ + markDismissed(messageId: string): Promise; + /** Track CTA click */ + trackClick(messageId: string): Promise<{ redirectUrl?: string }>; + /** Poll for new messages (use with setInterval) */ + pollMessages(intervalMs?: number): () => void; +} + +export function createBroadcastClient(config: BroadcastClientConfig): BroadcastClient { + const headers = async () => ({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, + 'x-product-id': config.productId, + 'x-platform': config.platform, + 'x-app-version': config.appVersion, + 'x-os-version': config.osVersion, + ...(config.countryCode && { 'x-country-code': config.countryCode }), + ...(config.regionCode && { 'x-region-code': config.regionCode }), + 'x-user-segments': (config.userSegments ?? ['free']).join(','), + }); + + const request = async (path: string, options?: RequestInit): Promise => { + const res = await fetch(`${config.baseUrl}${path}`, { + ...options, + headers: { + ...(await headers()), + ...(options?.headers || {}), + }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Broadcast API error: ${res.status} ${err}`); + } + return res.json() as Promise; + }; + + let pollInterval: ReturnType | null = null; + + return { + async listMessages() { + return request<{ messages: InAppMessage[] }>('/broadcasts'); + }, + + async markRead(messageId: string) { + await request(`/broadcasts/${messageId}/read`, { method: 'POST' }); + }, + + async markDismissed(messageId: string) { + await request(`/broadcasts/${messageId}/dismiss`, { method: 'POST' }); + }, + + async trackClick(messageId: string) { + return request<{ redirectUrl?: string }>(`/broadcasts/${messageId}/click`, { + method: 'POST', + }); + }, + + pollMessages(intervalMs = 60000) { + if (pollInterval) clearInterval(pollInterval); + pollInterval = setInterval(() => { + this.listMessages().catch(() => {}); + }, intervalMs); + return () => { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + }; + }, + }; +} + +// ============================================================================= +// React Hook (optional) +// ============================================================================= + +export function createUseBroadcast(client: BroadcastClient) { + return function useBroadcast() { + return { client }; + }; +} + +// ============================================================================= +// Deep Link Router +// ============================================================================= + +export { + DeepLinkRouter, + deepLinkRouter, + DeepLinkScreens, + createBroadcastDeepLink, + type DeepLinkRoute, + type DeepLinkHandler, +} from './deep-link.js'; + diff --git a/vendor/bytelyst/broadcast-client/tsconfig.json b/vendor/bytelyst/broadcast-client/tsconfig.json new file mode 100644 index 0000000..3686f56 --- /dev/null +++ b/vendor/bytelyst/broadcast-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vendor/bytelyst/celebrations/package.json b/vendor/bytelyst/celebrations/package.json new file mode 100644 index 0000000..9a150c5 --- /dev/null +++ b/vendor/bytelyst/celebrations/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/celebrations", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/celebrations/src/index.ts b/vendor/bytelyst/celebrations/src/index.ts new file mode 100644 index 0000000..0170132 --- /dev/null +++ b/vendor/bytelyst/celebrations/src/index.ts @@ -0,0 +1,25 @@ +export interface Celebration { + emoji: string; + title: string; +} + +const DEFAULT_CELEBRATION: Celebration = { + emoji: '👏', + title: 'Great Job!', +}; + +const BY_TYPE: Record = { + session_completed: { emoji: '🎉', title: 'Fast Complete!' }, + task_completed: { emoji: '✅', title: 'Well Done!' }, + streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' }, + achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' }, + level_up: { emoji: '⬆️', title: 'Level Up!' }, +}; + +export function createCelebrationEngine() { + return { + getCelebration(type: string): Celebration { + return BY_TYPE[type] ?? DEFAULT_CELEBRATION; + }, + }; +} diff --git a/vendor/bytelyst/celebrations/tsconfig.json b/vendor/bytelyst/celebrations/tsconfig.json new file mode 100644 index 0000000..01c4d9a --- /dev/null +++ b/vendor/bytelyst/celebrations/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/client-encrypt/package.json b/vendor/bytelyst/client-encrypt/package.json new file mode 100644 index 0000000..a1d2937 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/package.json @@ -0,0 +1,26 @@ +{ + "name": "@bytelyst/client-encrypt", + "version": "0.1.5", + "type": "module", + "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 --pool forks" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts new file mode 100644 index 0000000..0c7d044 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { + encryptField, + decryptField, + generateKey, + keyFromHex, + keyToHex, + deriveKey, +} from './aes-gcm.js'; +import { isEncryptedField } from './guards.js'; +import { toHex, fromHex } from './hex.js'; + +describe('encryptField / decryptField', () => { + it('roundtrip', async () => { + const key = await generateKey(); + const encrypted = await encryptField('Hello, World!', key, 'dek_test'); + const decrypted = await decryptField(encrypted, key); + expect(decrypted).toBe('Hello, World!'); + }); + + it('empty string', async () => { + const key = await generateKey(); + const encrypted = await encryptField('', key, 'dek_test'); + const decrypted = await decryptField(encrypted, key); + expect(decrypted).toBe(''); + }); + + it('unicode', async () => { + const key = await generateKey(); + const text = 'こんにちは世界 🌍 مرحبا Ñoño'; + const encrypted = await encryptField(text, key, 'dek_test'); + const decrypted = await decryptField(encrypted, key); + expect(decrypted).toBe(text); + }); + + it('large payload', async () => { + const key = await generateKey(); + const text = 'A'.repeat(100_000); + const encrypted = await encryptField(text, key, 'dek_test'); + const decrypted = await decryptField(encrypted, key); + expect(decrypted).toBe(text); + }); +}); + +describe('EncryptedField structure', () => { + it('has correct sentinel fields', async () => { + const key = await generateKey(); + const encrypted = await encryptField('test', key, 'dek_test'); + expect(encrypted.__encrypted).toBe(true); + expect(encrypted.v).toBe(1); + expect(encrypted.alg).toBe('aes-256-gcm'); + expect(encrypted.dekId).toBe('dek_test'); + }); + + it('has correct hex lengths', async () => { + const key = await generateKey(); + const encrypted = await encryptField('test', key, 'dek_test'); + expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex + expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex + expect(encrypted.ct.length).toBeGreaterThan(0); + }); + + it('unique IVs per encryption', async () => { + const key = await generateKey(); + const a = await encryptField('same', key, 'dek_test'); + const b = await encryptField('same', key, 'dek_test'); + expect(a.iv).not.toBe(b.iv); + expect(a.ct).not.toBe(b.ct); + }); +}); + +describe('AAD (Additional Authenticated Data)', () => { + it('roundtrip with AAD', async () => { + const key = await generateKey(); + const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx'); + const decrypted = await decryptField(encrypted, key, 'user:ctx'); + expect(decrypted).toBe('secret'); + }); + + it('wrong AAD fails', async () => { + const key = await generateKey(); + const encrypted = await encryptField('secret', key, 'dek_test', 'correct'); + await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow(); + }); + + it('missing AAD fails', async () => { + const key = await generateKey(); + const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad'); + await expect(decryptField(encrypted, key)).rejects.toThrow(); + }); +}); + +describe('wrong key', () => { + it('decrypt with wrong key fails', async () => { + const key = await generateKey(); + const wrongKey = await generateKey(); + const encrypted = await encryptField('secret', key, 'dek_test'); + await expect(decryptField(encrypted, wrongKey)).rejects.toThrow(); + }); +}); + +describe('keyFromHex / keyToHex', () => { + it('roundtrip', async () => { + const key = await generateKey(); + const hex = await keyToHex(key); + expect(hex.length).toBe(64); // 32 bytes = 64 hex chars + const restored = await keyFromHex(hex); + const encrypted = await encryptField('test', key, 'dek_test'); + const decrypted = await decryptField(encrypted, restored); + expect(decrypted).toBe('test'); + }); + + it('rejects invalid length', async () => { + await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key'); + }); +}); + +describe('deriveKey', () => { + it('derives consistent key from passphrase + salt', async () => { + const salt = new Uint8Array(16); + globalThis.crypto.getRandomValues(salt); + const key1 = await deriveKey('my-passphrase', salt, 1000, true); + const key2 = await deriveKey('my-passphrase', salt, 1000, true); + const hex1 = await keyToHex(key1); + const hex2 = await keyToHex(key2); + expect(hex1).toBe(hex2); + }); + + it('different passphrases produce different keys', async () => { + const salt = new Uint8Array(16); + globalThis.crypto.getRandomValues(salt); + const key1 = await deriveKey('pass-1', salt, 1000, true); + const key2 = await deriveKey('pass-2', salt, 1000, true); + const hex1 = await keyToHex(key1); + const hex2 = await keyToHex(key2); + expect(hex1).not.toBe(hex2); + }); + + it('derived key can encrypt/decrypt', async () => { + const salt = new Uint8Array(16); + globalThis.crypto.getRandomValues(salt); + const key = await deriveKey('test', salt, 1000, true); + const encrypted = await encryptField('hello', key, 'dek_test'); + const decrypted = await decryptField(encrypted, key); + expect(decrypted).toBe('hello'); + }); +}); + +describe('isEncryptedField', () => { + it('true for valid EncryptedField', async () => { + const key = await generateKey(); + const encrypted = await encryptField('test', key, 'dek_test'); + expect(isEncryptedField(encrypted)).toBe(true); + }); + + it('false for plain string', () => { + expect(isEncryptedField('just a string')).toBe(false); + }); + + it('false for null', () => { + expect(isEncryptedField(null)).toBe(false); + }); + + it('false for incomplete object', () => { + expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false); + }); +}); + +describe('hex utilities', () => { + it('toHex / fromHex roundtrip', () => { + const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]); + const hex = toHex(bytes); + expect(hex).toBe('000fffabcd'); + const restored = fromHex(hex); + expect(restored).toEqual(bytes); + }); + + it('fromHex rejects odd length', () => { + expect(() => fromHex('a')).toThrow('even length'); + }); +}); diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.ts new file mode 100644 index 0000000..764d932 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/aes-gcm.ts @@ -0,0 +1,215 @@ +/** + * @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API + * + * Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill). + * Produces EncryptedField objects wire-compatible with: + * - @bytelyst/field-encrypt (Node.js server) + * - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto) + */ + +import type { EncryptedField } from './types.js'; +import { toHex, fromHex } from './hex.js'; + +const ALGORITHM = 'AES-GCM'; +const KEY_SIZE_BITS = 256; +const IV_BYTES = 12; +const TAG_BITS = 128; + +/** Get the SubtleCrypto instance (browser or globalThis). */ +function getSubtle(): SubtleCrypto { + if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { + return globalThis.crypto.subtle; + } + throw new Error( + '@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' + + 'Use a polyfill in React Native (e.g., expo-crypto).' + ); +} + +/** Get the crypto object for random bytes. */ +function getCrypto(): Crypto { + if (typeof globalThis !== 'undefined' && globalThis.crypto) { + return globalThis.crypto; + } + throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.'); +} + +/** + * Encrypt a plaintext string with AES-256-GCM using Web Crypto API. + * + * @param plaintext - UTF-8 string to encrypt + * @param key - CryptoKey (AES-GCM, 256-bit) + * @param dekId - DEK identifier stored in the output + * @param aad - Optional additional authenticated data + * @returns EncryptedField with hex-encoded ciphertext, IV, and tag + */ +export async function encryptField( + plaintext: string, + key: CryptoKey, + dekId: string, + aad?: string +): Promise { + const subtle = getSubtle(); + const crypto = getCrypto(); + + const iv = new Uint8Array(IV_BYTES); + crypto.getRandomValues(iv); + + const encoder = new TextEncoder(); + const plaintextBytes = encoder.encode(plaintext); + + const params: AesGcmParams = { + name: ALGORITHM, + iv: iv.buffer as ArrayBuffer, + tagLength: TAG_BITS, + }; + + if (aad) { + params.additionalData = encoder.encode(aad).buffer as ArrayBuffer; + } + + // Web Crypto returns ciphertext || tag concatenated + const ciphertextWithTag = new Uint8Array( + await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer) + ); + + const tagOffset = ciphertextWithTag.length - TAG_BITS / 8; + const ct = ciphertextWithTag.slice(0, tagOffset); + const tag = ciphertextWithTag.slice(tagOffset); + + return { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: toHex(ct), + iv: toHex(iv), + tag: toHex(tag), + dekId, + }; +} + +/** + * Decrypt an EncryptedField back to plaintext. + * + * @param field - EncryptedField object + * @param key - CryptoKey (must match the key used to encrypt) + * @param aad - Optional AAD (must match the AAD used during encryption) + * @returns Decrypted UTF-8 string + * @throws DOMException if authentication tag verification fails + */ +export async function decryptField( + field: EncryptedField, + key: CryptoKey, + aad?: string +): Promise { + const subtle = getSubtle(); + + const iv = fromHex(field.iv); + const ct = fromHex(field.ct); + const tag = fromHex(field.tag); + + // Web Crypto expects ciphertext || tag concatenated + const ciphertextWithTag = new Uint8Array(ct.length + tag.length); + ciphertextWithTag.set(ct, 0); + ciphertextWithTag.set(tag, ct.length); + + const params: AesGcmParams = { + name: ALGORITHM, + iv: iv.buffer as ArrayBuffer, + tagLength: TAG_BITS, + }; + + if (aad) { + params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer; + } + + const plaintextBytes = new Uint8Array( + await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer) + ); + + return new TextDecoder().decode(plaintextBytes); +} + +/** + * Generate a random AES-256-GCM CryptoKey. + * + * @param extractable - Whether the key material can be exported (default: true). + * Set to `false` for non-extractable keys stored in IndexedDB. + */ +export async function generateKey(extractable = true): Promise { + const subtle = getSubtle(); + return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [ + 'encrypt', + 'decrypt', + ]); +} + +/** + * Import a hex-encoded key string as a CryptoKey. + * + * @param hex - 64 hex chars = 32 bytes + * @param extractable - Whether the imported key can be exported (default: true) + */ +export async function keyFromHex(hex: string, extractable = true): Promise { + const subtle = getSubtle(); + const keyBytes = fromHex(hex); + if (keyBytes.length !== KEY_SIZE_BITS / 8) { + throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`); + } + return subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: ALGORITHM, length: KEY_SIZE_BITS }, + extractable, + ['encrypt', 'decrypt'] + ); +} + +/** + * Export a CryptoKey to a hex-encoded string. + * Only works if the key was created with `extractable: true`. + */ +export async function keyToHex(key: CryptoKey): Promise { + const subtle = getSubtle(); + const raw = new Uint8Array(await subtle.exportKey('raw', key)); + return toHex(raw); +} + +/** + * Derive an AES-256 key from a passphrase using PBKDF2. + * + * @param passphrase - User passphrase + * @param salt - Random salt (at least 16 bytes recommended) + * @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023) + * @param extractable - Whether derived key can be exported (default: false) + */ +export async function deriveKey( + passphrase: string, + salt: Uint8Array, + iterations = 600_000, + extractable = false +): Promise { + const subtle = getSubtle(); + const encoder = new TextEncoder(); + + const baseKey = await subtle.importKey( + 'raw', + encoder.encode(passphrase).buffer as ArrayBuffer, + 'PBKDF2', + false, + ['deriveKey'] + ); + + return subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt.buffer as ArrayBuffer, + iterations, + hash: 'SHA-256', + }, + baseKey, + { name: ALGORITHM, length: KEY_SIZE_BITS }, + extractable, + ['encrypt', 'decrypt'] + ); +} diff --git a/vendor/bytelyst/client-encrypt/src/guards.ts b/vendor/bytelyst/client-encrypt/src/guards.ts new file mode 100644 index 0000000..7f7a5f9 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/guards.ts @@ -0,0 +1,22 @@ +/** + * @bytelyst/client-encrypt — Type guards + * + * Compatible with @bytelyst/field-encrypt isEncryptedField() on the server. + */ + +import type { EncryptedField } from './types.js'; + +/** Check if a value is an EncryptedField object. */ +export function isEncryptedField(value: unknown): value is EncryptedField { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + obj.__encrypted === true && + obj.v !== undefined && + obj.alg !== undefined && + typeof obj.ct === 'string' && + typeof obj.iv === 'string' && + typeof obj.tag === 'string' && + typeof obj.dekId === 'string' + ); +} diff --git a/vendor/bytelyst/client-encrypt/src/hex.ts b/vendor/bytelyst/client-encrypt/src/hex.ts new file mode 100644 index 0000000..9caf472 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/hex.ts @@ -0,0 +1,28 @@ +/** + * @bytelyst/client-encrypt — Hex encoding utilities + * + * Converts between Uint8Array and hex strings. + * Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js) + * and BLFieldEncrypt (Swift/Kotlin). + */ + +/** Encode a Uint8Array to a lowercase hex string. */ +export function toHex(bytes: Uint8Array): string { + const parts: string[] = new Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + parts[i] = bytes[i].toString(16).padStart(2, '0'); + } + return parts.join(''); +} + +/** Decode a hex string to a Uint8Array. */ +export function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error(`Hex string must have even length, got ${hex.length}`); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/vendor/bytelyst/client-encrypt/src/index.ts b/vendor/bytelyst/client-encrypt/src/index.ts new file mode 100644 index 0000000..951c981 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/index.ts @@ -0,0 +1,36 @@ +/** + * @bytelyst/client-encrypt + * + * Client-side AES-256-GCM field encryption using Web Crypto API. + * Works in browsers and React Native (with SubtleCrypto polyfill). + * Wire-compatible with @bytelyst/field-encrypt (server) and + * BLFieldEncrypt (Swift/Kotlin native SDKs). + * + * @example + * ```typescript + * import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt'; + * + * const key = await generateKey(); + * const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes'); + * const plaintext = await decryptField(encrypted, key); + * ``` + */ + +// ── Main API ──────────────────────────────────────── +export { + encryptField, + decryptField, + generateKey, + keyFromHex, + keyToHex, + deriveKey, +} from './aes-gcm.js'; + +// ── Type guards ───────────────────────────────────── +export { isEncryptedField } from './guards.js'; + +// ── Hex utilities ─────────────────────────────────── +export { toHex, fromHex } from './hex.js'; + +// ── Types ─────────────────────────────────────────── +export type { EncryptedField, ClientEncryptContext } from './types.js'; diff --git a/vendor/bytelyst/client-encrypt/src/types.ts b/vendor/bytelyst/client-encrypt/src/types.ts new file mode 100644 index 0000000..59ea370 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/src/types.ts @@ -0,0 +1,33 @@ +/** + * @bytelyst/client-encrypt — Types + * + * Shared type definitions for client-side field encryption. + * Wire-compatible with @bytelyst/field-encrypt (server) and + * BLFieldEncrypt (Swift/Kotlin native SDKs). + */ + +/** Encrypted field stored in Cosmos DB or API responses. */ +export interface EncryptedField { + /** Sentinel — always true for encrypted fields. */ + readonly __encrypted: true; + /** Schema version for future algorithm changes. */ + readonly v: 1; + /** Algorithm identifier. */ + readonly alg: 'aes-256-gcm'; + /** Ciphertext (hex-encoded). */ + readonly ct: string; + /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ + readonly iv: string; + /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ + readonly tag: string; + /** DEK identifier — identifies which key to use for decryption. */ + readonly dekId: string; +} + +/** Options for encrypt/decrypt operations. */ +export interface ClientEncryptContext { + /** Scope for DEK isolation (typically userId). */ + readonly userId: string; + /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ + readonly context: string; +} diff --git a/vendor/bytelyst/client-encrypt/tsconfig.json b/vendor/bytelyst/client-encrypt/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/client-encrypt/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/vendor/bytelyst/create-app/package.json b/vendor/bytelyst/create-app/package.json new file mode 100644 index 0000000..e731df4 --- /dev/null +++ b/vendor/bytelyst/create-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "@bytelyst/create-app", + "version": "0.1.3", + "description": "CLI tools for scaffolding ByteLyst product repos and code", + "type": "module", + "bin": { + "create-app": "./dist/scaffolder.js", + "gen-api-route": "./dist/generators/api-routes.js", + "gen-agents-md": "./dist/generators/agents-md.js" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "create-app": "tsx src/scaffolder.ts", + "gen:api-route": "tsx src/generators/api-routes.ts", + "gen:agents-md": "tsx src/generators/agents-md.ts" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts b/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts new file mode 100644 index 0000000..7d5eac7 --- /dev/null +++ b/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { generateFiles, type ProductManifest } from '../scaffolder.js'; + +function makeManifest(overrides: Partial = {}): ProductManifest { + return { + productId: 'testapp', + displayName: 'TestApp', + tagline: 'A test application', + domain: 'testapp.dev', + backendPort: 4050, + primarySurface: 'web', + platforms: ['web'], + features: ['auth', 'telemetry'], + ...overrides, + }; +} + +describe('generateFiles', () => { + it('generates root files', () => { + const files = generateFiles(makeManifest()); + const paths = files.map(f => f.path); + + expect(paths).toContain('shared/product.json'); + expect(paths).toContain('.gitignore'); + expect(paths).toContain('.env.example'); + expect(paths).toContain('README.md'); + }); + + it('always generates backend files', () => { + const files = generateFiles(makeManifest()); + const paths = files.map(f => f.path); + + expect(paths).toContain('backend/package.json'); + expect(paths).toContain('backend/src/server.ts'); + expect(paths).toContain('backend/src/lib/config.ts'); + expect(paths).toContain('backend/src/lib/auth.ts'); + expect(paths).toContain('backend/src/lib/datastore.ts'); + }); + + it('generates web files when platform includes web', () => { + const files = generateFiles(makeManifest({ platforms: ['web'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('web/package.json'); + expect(paths).toContain('web/next.config.ts'); + expect(paths).toContain('web/src/app/layout.tsx'); + expect(paths).toContain('web/src/app/page.tsx'); + expect(paths).toContain('web/src/lib/product-config.ts'); + }); + + it('does not generate web files when platform excludes web', () => { + const files = generateFiles(makeManifest({ platforms: ['mobile'] })); + const paths = files.map(f => f.path); + + expect(paths).not.toContain('web/package.json'); + expect(paths).not.toContain('web/src/app/page.tsx'); + }); + + it('generates mobile files when platform includes mobile', () => { + const files = generateFiles(makeManifest({ platforms: ['mobile'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('mobile/package.json'); + expect(paths).toContain('mobile/app.json'); + expect(paths).toContain('mobile/src/app/index.tsx'); + }); + + it('does not generate mobile files when platform excludes mobile', () => { + const files = generateFiles(makeManifest({ platforms: ['web'] })); + const paths = files.map(f => f.path); + + expect(paths).not.toContain('mobile/package.json'); + }); + + it('replaces product ID in generated content', () => { + const files = generateFiles(makeManifest({ productId: 'myproduct' })); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('"productId": "myproduct"'); + }); + + it('replaces display name in generated content', () => { + const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' })); + const readme = files.find(f => f.path === 'README.md')!; + expect(readme.content).toContain('# AwesomeApp'); + }); + + it('replaces backend port in config', () => { + const files = generateFiles(makeManifest({ backendPort: 4099 })); + const config = files.find(f => f.path === 'backend/src/lib/config.ts')!; + expect(config.content).toContain('4099'); + }); + + it('includes ios bundle ID when ios platform selected', () => { + const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' })); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('com.bytelyst.myapp'); + }); + + it('includes android bundle ID when android platform selected', () => { + const files = generateFiles( + makeManifest({ platforms: ['web', 'android'], productId: 'myapp' }) + ); + const productJson = files.find(f => f.path === 'shared/product.json')!; + expect(productJson.content).toContain('com.myapp.app'); + }); + + it('includes backend env vars in .env.example', () => { + const files = generateFiles(makeManifest({ backendPort: 4050 })); + const env = files.find(f => f.path === '.env.example')!; + expect(env.content).toContain('PORT=4050'); + expect(env.content).toContain('JWT_SECRET'); + }); + + it('generates correct web product-config', () => { + const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 })); + const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!; + expect(webConfig.content).toContain("productId: 'testprod'"); + expect(webConfig.content).toContain('4077'); + }); + + it('generates all platforms when all selected', () => { + const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] })); + const paths = files.map(f => f.path); + + expect(paths).toContain('web/package.json'); + expect(paths).toContain('mobile/package.json'); + // Backend is always included + expect(paths).toContain('backend/package.json'); + }); + + it('server.ts includes display name in log message', () => { + const files = generateFiles(makeManifest({ displayName: 'CoolProduct' })); + const server = files.find(f => f.path === 'backend/src/server.ts')!; + expect(server.content).toContain('CoolProduct backend listening'); + }); +}); diff --git a/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts b/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts new file mode 100644 index 0000000..2321866 --- /dev/null +++ b/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { renderTemplate } from '../lib/template-engine.js'; + +describe('renderTemplate', () => { + it('replaces simple variables', () => { + const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('replaces multiple variables', () => { + const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' }); + expect(result).toBe('foo and bar'); + }); + + it('replaces numeric variables', () => { + const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 }); + expect(result).toBe('Port: 4017'); + }); + + it('leaves unknown variables intact', () => { + const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' }); + expect(result).toBe('yes {{UNKNOWN}}'); + }); + + it('includes IF block when truthy', () => { + const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true }); + expect(result).toBe('web here'); + }); + + it('excludes IF block when falsy', () => { + const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', { + HAS_WEB: false, + }); + expect(result).toBe('beforeafter'); + }); + + it('includes UNLESS block when falsy', () => { + const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { + HAS_WEB: false, + }); + expect(result).toBe('no web'); + }); + + it('excludes UNLESS block when truthy', () => { + const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { + HAS_WEB: true, + }); + expect(result).toBe(''); + }); + + it('handles nested variables inside IF blocks', () => { + const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', { + HAS_WEB: true, + PORT: 3000, + }); + expect(result).toBe('Port: 3000'); + }); + + it('handles multiline IF blocks', () => { + const tmpl = `start +{{#IF HAS_BACKEND}}backend line 1 +backend line 2 +{{/IF HAS_BACKEND}}end`; + const result = renderTemplate(tmpl, { HAS_BACKEND: true }); + expect(result).toContain('backend line 1'); + expect(result).toContain('backend line 2'); + expect(result).toContain('start'); + expect(result).toContain('end'); + }); + + it('handles multiple IF blocks', () => { + const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}'; + expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|'); + expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb'); + expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb'); + }); +}); diff --git a/vendor/bytelyst/create-app/src/generators/agents-md.ts b/vendor/bytelyst/create-app/src/generators/agents-md.ts new file mode 100644 index 0000000..9c4b9bd --- /dev/null +++ b/vendor/bytelyst/create-app/src/generators/agents-md.ts @@ -0,0 +1,605 @@ +#!/usr/bin/env node +/** + * AGENTS.md Auto-Generator + * + * Generates AGENTS.md from product.json + repo directory scan. + * Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules + * + * Usage: + * npx tsx agents-md.ts --repo /path/to/product-repo + * npx tsx agents-md.ts --repo /path/to/product-repo --dry-run + * npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves sections + * + * @module @bytelyst/create-app/generators/agents-md + */ + +/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// ── CLI ────────────────────────────────────────────────────────────────────── + +interface Options { + repo: string; + dryRun: boolean; + update: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + const options: Options = { repo: '.', dryRun: false, update: false }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--repo' || arg === '-r') options.repo = args[++i]; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--update' || arg === '-u') options.update = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +function showHelp(): void { + console.log(` +AGENTS.md Auto-Generator + +Generates AGENTS.md from product.json + repo scan, plus symlinks for +CLAUDE.md, .cursorrules, and .windsurfrules. + +Usage: + npx tsx agents-md.ts --repo [--dry-run] [--update] + +Options: + --repo, -r Path to product repo root (default: ".") + --dry-run, -d Preview without writing files + --update, -u Preserve sections in existing AGENTS.md + --help, -h Show this help + +Custom Sections: + Wrap any hand-written content in / + markers. The --update flag preserves these sections when regenerating. +`); +} + +// ── Product.json Loader ────────────────────────────────────────────────────── + +interface ProductManifest { + productId: string; + displayName: string; + tagline?: string; + domain?: string; + backendPort?: number; + primarySurface?: string; + mobileCompanion?: boolean; + bundleIds?: Record; + bundleId?: string; + version?: string; + description?: string; + licensePrefix?: string; + configDirName?: string; + envVarPrefix?: string; +} + +async function loadProductJson(repoPath: string): Promise { + const candidates = [ + path.join(repoPath, 'shared', 'product.json'), + path.join(repoPath, 'product.json'), + ]; + + for (const p of candidates) { + try { + const raw = await fs.readFile(p, 'utf-8'); + return JSON.parse(raw); + } catch { + // try next + } + } + + throw new Error('product.json not found in shared/ or repo root'); +} + +// ── Repo Scanner ───────────────────────────────────────────────────────────── + +interface RepoInfo { + repoName: string; + hasFastifyBackend: boolean; + hasNextWeb: boolean; + hasExpoMobile: boolean; + hasSwiftIos: boolean; + hasKotlinAndroid: boolean; + hasKmpShared: boolean; + backendModules: string[]; + backendTestCount: number; + webTestCount: number; + mobileTestCount: number; + backendLibFiles: string[]; + webLibFiles: string[]; + cosmosContainers: string[]; + techStack: { layer: string; tech: string }[]; + buildCommands: string[]; +} + +async function dirExists(p: string): Promise { + try { + const st = await fs.stat(p); + return st.isDirectory(); + } catch { + return false; + } +} + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +function countTestsInFiles(dir: string): number { + try { + const output = execSync( + `grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`, + { encoding: 'utf-8', timeout: 5000 } + ); + return parseInt(output.trim()) || 0; + } catch { + return 0; + } +} + +async function listDirs(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter(e => e.isDirectory()).map(e => e.name); + } catch { + return []; + } +} + +async function listFiles(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter(e => e.isFile()).map(e => e.name); + } catch { + return []; + } +} + +async function scanRepo(repoPath: string, manifest: ProductManifest): Promise { + const repoName = path.basename(repoPath); + + // Detect surfaces + const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src')); + const hasNextWeb = + (await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) || + (await fileExists(path.join(repoPath, 'web', 'next.config.js'))) || + (await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts'))); + const hasExpoMobile = + (await fileExists(path.join(repoPath, 'mobile', 'app.json'))) || + (await fileExists(path.join(repoPath, 'app.json'))); + const hasSwiftIos = await dirExists(path.join(repoPath, 'ios')); + const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android')); + const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src')); + + // Backend modules + const modulesDir = path.join(repoPath, 'backend', 'src', 'modules'); + const backendModules = await listDirs(modulesDir); + + // Backend lib files + const libDir = path.join(repoPath, 'backend', 'src', 'lib'); + const backendLibFiles = (await listFiles(libDir)).filter( + f => f.endsWith('.ts') && !f.endsWith('.test.ts') + ); + + // Web lib files + let webLibDir = path.join(repoPath, 'web', 'src', 'lib'); + if (!(await dirExists(webLibDir))) { + webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib'); + } + const webLibFiles = (await listFiles(webLibDir)).filter( + f => f.endsWith('.ts') && !f.endsWith('.test.ts') + ); + + // Test counts + const backendTestCount = hasFastifyBackend + ? countTestsInFiles(path.join(repoPath, 'backend', 'src')) + : 0; + + let webTestDir = path.join(repoPath, 'web', 'src'); + if (!(await dirExists(webTestDir))) { + webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src'); + } + const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0; + + const mobileTestDir = hasExpoMobile + ? (await dirExists(path.join(repoPath, 'mobile'))) + ? path.join(repoPath, 'mobile') + : repoPath + : ''; + const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0; + + // Cosmos containers — scan backend types files + const cosmosContainers: string[] = []; + for (const mod of backendModules) { + const typesFile = path.join(modulesDir, mod, 'types.ts'); + if (await fileExists(typesFile)) { + cosmosContainers.push(mod.replace(/-/g, '_')); + } + } + + // Tech stack + const techStack: { layer: string; tech: string }[] = []; + if (hasFastifyBackend) + techStack.push({ + layer: 'Backend', + tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`, + }); + if (hasNextWeb) + techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' }); + if (hasExpoMobile) + techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' }); + if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' }); + if (hasKotlinAndroid) + techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' }); + if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' }); + techStack.push({ + layer: 'Platform', + tech: 'platform-service (port 4003) for auth, flags, telemetry, billing', + }); + techStack.push({ + layer: 'Database', + tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`, + }); + + // Build commands + const buildCommands: string[] = []; + if (hasFastifyBackend) { + const port = manifest.backendPort ?? 4000; + buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`); + buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`); + buildCommands.push(`cd backend && npm test # Vitest tests`); + } + if (hasNextWeb) { + buildCommands.push(`cd web && npm run dev # Dev server`); + buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`); + buildCommands.push(`cd web && npm run build # Production build`); + } + if (hasExpoMobile) { + buildCommands.push(`cd mobile && npm start # Expo dev server`); + buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`); + } + + return { + repoName, + hasFastifyBackend, + hasNextWeb, + hasExpoMobile, + hasSwiftIos, + hasKotlinAndroid, + hasKmpShared, + backendModules, + backendTestCount, + webTestCount, + mobileTestCount, + backendLibFiles, + webLibFiles, + cosmosContainers, + techStack, + buildCommands, + }; +} + +// ── Markdown Generator ─────────────────────────────────────────────────────── + +function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string { + const { productId, displayName, domain, tagline } = manifest; + const repoDesc = tagline ?? `${displayName} product`; + + const lines: string[] = []; + + // ── Header + lines.push(`# AGENTS.md — AI Coding Agent Instructions`); + lines.push(''); + lines.push( + `> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.` + ); + lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`); + lines.push(''); + lines.push('---'); + lines.push(''); + + // ── 1. Project Identity + lines.push('## 1. Project Identity'); + lines.push(''); + lines.push('| Key | Value |'); + lines.push('|-----|-------|'); + lines.push(`| **Product** | ${displayName} |`); + lines.push(`| **Product ID** | \`${productId}\` |`); + if (domain) lines.push(`| **Domain** | ${domain} |`); + lines.push(`| **Repo** | \`${info.repoName}\` |`); + lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`); + lines.push(''); + + // ── 2. Repo Layout (simplified tree) + lines.push('## 2. Repo Layout'); + lines.push(''); + lines.push('```'); + lines.push(`${info.repoName}/`); + if (info.hasFastifyBackend) { + const port = manifest.backendPort ?? 4000; + lines.push( + `├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})` + ); + lines.push(`│ ├── src/`); + if (info.backendLibFiles.length > 0) { + lines.push(`│ │ ├── lib/ # Shared backend wiring`); + for (const f of info.backendLibFiles.slice(0, 8)) { + lines.push(`│ │ │ ├── ${f}`); + } + if (info.backendLibFiles.length > 8) { + lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`); + } + } + if (info.backendModules.length > 0) { + lines.push(`│ │ ├── modules/`); + for (const m of info.backendModules) { + lines.push(`│ │ │ ├── ${m}/`); + } + } + lines.push(`│ │ └── server.ts`); + lines.push(`│ ├── package.json`); + lines.push(`│ └── tsconfig.json`); + lines.push('│'); + } + if (info.hasNextWeb) { + lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`); + lines.push(`│ ├── src/`); + lines.push(`│ │ ├── app/ # App Router pages`); + if (info.webLibFiles.length > 0) { + lines.push(`│ │ └── lib/ # Pure TS clients + config`); + } + lines.push(`│ ├── package.json`); + lines.push(`│ └── tsconfig.json`); + lines.push('│'); + } + if (info.hasExpoMobile) { + lines.push(`├── mobile/ # React Native + Expo`); + lines.push('│'); + } + if (info.hasSwiftIos) { + lines.push(`├── ios/ # SwiftUI native app`); + lines.push('│'); + } + if (info.hasKotlinAndroid) { + lines.push(`├── android/ # Jetpack Compose`); + lines.push('│'); + } + lines.push(`├── shared/`); + lines.push(`│ └── product.json # Canonical product identity`); + lines.push(`├── AGENTS.md # This file`); + lines.push(`└── README.md`); + lines.push('```'); + lines.push(''); + + // ── 3. Tech Stack + lines.push('## 3. Tech Stack'); + lines.push(''); + lines.push('| Layer | Technology |'); + lines.push('|-------|-----------|'); + for (const { layer, tech } of info.techStack) { + lines.push(`| **${layer}** | ${tech} |`); + } + + // Test counts + const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount; + if (totalTests > 0) { + const parts: string[] = []; + if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`); + if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`); + if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`); + lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`); + } + lines.push(''); + + // ── 4. Coding Conventions + lines.push('## 4. Coding Conventions'); + lines.push(''); + lines.push('### MUST follow'); + lines.push(''); + lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`); + if (info.hasFastifyBackend) { + lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern'); + lines.push( + '- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls' + ); + } + if (info.hasNextWeb) { + lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports'); + lines.push('- Web components in `web/src/components/` — React UI only'); + } + if (info.hasExpoMobile) { + lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports'); + } + lines.push( + '- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`' + ); + lines.push(''); + lines.push('### MUST NOT do'); + lines.push(''); + lines.push( + '- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify' + ); + lines.push('- Never use `any` type — use Zod inference or explicit types'); + lines.push('- Never hardcode colors — use theme tokens'); + lines.push('- Never hardcode API URLs — use env vars or config'); + lines.push(`- Never hardcode product ID — use \`productConfig.productId\``); + lines.push('- Never modify tests to make them pass — fix the actual code'); + lines.push('- Never delete existing comments or documentation unless explicitly asked'); + lines.push('- Never add emojis to code unless explicitly asked'); + lines.push(''); + + // ── 5. Build & Test Commands + if (info.buildCommands.length > 0) { + lines.push('## 5. Build & Test Commands'); + lines.push(''); + lines.push('```bash'); + for (const cmd of info.buildCommands) { + lines.push(cmd); + } + lines.push('```'); + lines.push(''); + } + + // ── 6. Backend API Modules (if applicable) + if (info.hasFastifyBackend && info.backendModules.length > 0) { + lines.push('## 6. Backend Modules'); + lines.push(''); + lines.push('| Module | Container | Description |'); + lines.push('|--------|-----------|-------------|'); + for (const mod of info.backendModules) { + const container = mod.replace(/-/g, '_'); + lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`); + } + lines.push(''); + } + + // ── Custom section placeholder + lines.push(''); + lines.push(''); + lines.push(''); + + return lines.join('\n'); +} + +// ── Custom Section Preservation ────────────────────────────────────────────── + +function extractCustomSections(content: string): Map { + const sections = new Map(); + const regex = /\n([\s\S]*?)/g; + let match; + while ((match = regex.exec(content)) !== null) { + sections.set(match[1], match[2]); + } + return sections; +} + +function mergeCustomSections(newContent: string, existing: Map): string { + let result = newContent; + for (const [key, value] of existing) { + const placeholder = `\n`; + const replacement = `\n${value}`; + result = result.replace(placeholder, replacement); + } + return result; +} + +// ── Symlink Manager ────────────────────────────────────────────────────────── + +async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise { + const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules']; + + for (const target of targets) { + const linkPath = path.join(repoPath, target); + const exists = await fileExists(linkPath); + + if (exists) { + try { + const stat = await fs.lstat(linkPath); + if (stat.isSymbolicLink()) { + const linkTarget = await fs.readlink(linkPath); + if (linkTarget === 'AGENTS.md') { + continue; // already correct + } + } + } catch { + // not a symlink + } + } + + if (dryRun) { + console.log(` 📝 Would create symlink: ${target} → AGENTS.md`); + } else { + try { + if (exists) await fs.unlink(linkPath); + await fs.symlink('AGENTS.md', linkPath); + console.log(` ✅ ${target} → AGENTS.md`); + } catch (err) { + console.log( + ` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const { repo, dryRun, update } = parseArgs(); + const repoPath = path.resolve(repo); + + console.log(`\n📄 AGENTS.md Generator`); + console.log(` Repo: ${repoPath}`); + if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); + if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n'); + + // Load product.json + const manifest = await loadProductJson(repoPath); + console.log(` Product: ${manifest.displayName} (${manifest.productId})`); + + // Scan repo + const info = await scanRepo(repoPath, manifest); + console.log(` Backend modules: ${info.backendModules.length}`); + console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`); + console.log(''); + + // Generate content + let content = generateAgentsMd(manifest, info); + + // Merge custom sections if updating + if (update) { + const agentsPath = path.join(repoPath, 'AGENTS.md'); + try { + const existing = await fs.readFile(agentsPath, 'utf-8'); + const customSections = extractCustomSections(existing); + if (customSections.size > 0) { + console.log(` 🔄 Preserving ${customSections.size} custom section(s)`); + content = mergeCustomSections(content, customSections); + } + } catch { + console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from'); + } + } + + if (dryRun) { + console.log('── AGENTS.md ──────────────────────────────────────'); + console.log(content); + console.log('\n── Symlinks ──────────────────────────────────────'); + await ensureSymlinks(repoPath, true); + console.log('\n✨ Dry run complete.'); + return; + } + + // Write AGENTS.md + const agentsPath = path.join(repoPath, 'AGENTS.md'); + await fs.writeFile(agentsPath, content, 'utf-8'); + console.log(` ✅ AGENTS.md written`); + + // Ensure symlinks + await ensureSymlinks(repoPath, false); + + console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`); +} + +main().catch(err => { + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/vendor/bytelyst/create-app/src/generators/api-routes.ts b/vendor/bytelyst/create-app/src/generators/api-routes.ts new file mode 100644 index 0000000..6c38101 --- /dev/null +++ b/vendor/bytelyst/create-app/src/generators/api-routes.ts @@ -0,0 +1,770 @@ +#!/usr/bin/env node +/** + * API Route Generator — Next.js App Router + * + * Generates two files: + * src/app/api//route.ts — GET (list) + POST (create) + * src/app/api//[id]/route.ts — GET (detail) + PATCH (update) + DELETE + * + * Follows the pattern used across ByteLyst product dashboards: + * - Named exports (export const GET, POST, PATCH, DELETE) + * - withErrorHandler HOF wrapper + * - Auth via getCurrentUser / getAccessToken + * - Zod validation on POST/PATCH bodies + * - NextRequest + NextResponse + * + * Usage: + * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src + * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src + * + * Modes: + * --mode direct (default) Direct Cosmos DB access via repository functions + * --mode proxy Proxy to product backend via fetch (for thin web clients) + * + * @module @bytelyst/create-app/generators/api-routes + */ + +/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +// ── CLI ────────────────────────────────────────────────────────────────────── + +interface Options { + name: string; + fields: string; + target: string; + mode: 'direct' | 'proxy'; + methods: string[]; + withHandler: boolean; + dryRun: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + const options: Options = { + name: '', + fields: '', + target: './src', + mode: 'direct', + methods: ['GET', 'POST', 'PATCH', 'DELETE'], + withHandler: true, + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--name' || arg === '-n') options.name = args[++i]; + else if (arg === '--fields' || arg === '-f') options.fields = args[++i]; + else if (arg === '--target' || arg === '-t') options.target = args[++i]; + else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy'; + else if (arg === '--methods') + options.methods = args[++i].split(',').map(m => m.trim().toUpperCase()); + else if (arg === '--no-handler') options.withHandler = false; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + if (!options.name) { + console.error('Error: --name is required'); + showHelp(); + process.exit(1); + } + + if (!/^[a-z][a-z0-9-]*$/.test(options.name)) { + console.error('Error: --name must be lowercase alphanumeric with optional hyphens'); + process.exit(1); + } + + if (!['direct', 'proxy'].includes(options.mode)) { + console.error('Error: --mode must be "direct" or "proxy"'); + process.exit(1); + } + + return options; +} + +function showHelp(): void { + console.log(` +API Route Generator — Next.js App Router + +Generates CRUD API route files for a Next.js App Router project. + +Usage: + npx tsx api-routes.ts --name [--fields ""] [options] + +Options: + --name, -n Entity name (e.g., "tasks", "sessions") [required] + --fields, -f Comma-separated field definitions [optional for proxy mode] + --target, -t Target src/ directory (default: "./src") + --mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct] + --methods HTTP methods to generate (default: GET,POST,PATCH,DELETE) + --no-handler Skip withErrorHandler wrapper + --dry-run, -d Preview without writing files + --help, -h Show this help + +Field Types (same as gen-module): + string z.string() + number z.number() + boolean z.boolean() + date z.string().datetime() + enum(a,b,c) z.enum(["a","b","c"]) + Append ? for optional fields + +Modes: + direct — Generates route files that call repository functions (direct Cosmos DB). + Also generates a lib/repositories/.ts and lib/schemas/.ts. + proxy — Generates route files that proxy to a product backend via fetch. + Requires lib/api-helpers.ts to exist (createApiClient pattern). + +Examples: + # Direct mode (full CRUD with Cosmos) + npx tsx api-routes.ts --name tasks \\ + --fields "title:string,status:enum(pending,active,done),priority:number?" \\ + --target ./src + + # Proxy mode (thin web client forwarding to backend) + npx tsx api-routes.ts --name tasks --mode proxy --target ./src + + # Preview only + npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run +`); +} + +// ── Field Parser ───────────────────────────────────────────────────────────── + +interface ParsedField { + name: string; + type: string; + optional: boolean; + zodType: string; + tsType: string; + enumValues: string[] | null; +} + +function splitFields(str: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const ch of str) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + parts.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +function parseFields(fieldsStr: string): ParsedField[] { + if (!fieldsStr) return []; + const fields: ParsedField[] = []; + const fieldDefs = splitFields(fieldsStr); + + for (const raw of fieldDefs) { + const def = raw.trim(); + if (!def) continue; + + const optional = def.endsWith('?'); + const cleaned = optional ? def.slice(0, -1) : def; + const colonIdx = cleaned.indexOf(':'); + if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`); + + const name = cleaned.slice(0, colonIdx).trim(); + const type = cleaned.slice(colonIdx + 1).trim(); + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`Invalid field name: ${name}`); + } + + let zodType: string; + let tsType: string; + let enumValues: string[] | null = null; + + if (type.startsWith('enum(') && type.endsWith(')')) { + enumValues = type + .slice(5, -1) + .split(',') + .map(v => v.trim()) + .filter(Boolean); + if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`); + zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`; + tsType = enumValues.map(v => `'${v}'`).join(' | '); + } else { + const MAP: Record = { + string: { zod: 'z.string().min(1)', ts: 'string' }, + number: { zod: 'z.number()', ts: 'number' }, + boolean: { zod: 'z.boolean()', ts: 'boolean' }, + date: { zod: 'z.string().datetime()', ts: 'string' }, + datetime: { zod: 'z.string().datetime()', ts: 'string' }, + 'string[]': { zod: 'z.array(z.string())', ts: 'string[]' }, + 'number[]': { zod: 'z.array(z.number())', ts: 'number[]' }, + }; + const mapping = MAP[type]; + if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`); + zodType = mapping.zod; + tsType = mapping.ts; + } + + fields.push({ name, type, optional, zodType, tsType, enumValues }); + } + return fields; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function pascal(s: string): string { + return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase()); +} +// ── Proxy Mode Templates ──────────────────────────────────────────────────── + +function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string { + const hasGet = methods.includes('GET'); + const hasPost = methods.includes('POST'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getAccessToken } from '@/lib/api-helpers'; +${handlerImport} +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const url = new URL(req.url); + const params = url.searchParams.toString(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, { + headers: { Authorization: \`Bearer \${token}\` }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasPost) { + out += ` +export const POST = ${wrap(`async (req: NextRequest) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, { + method: 'POST', + headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + return out; +} + +function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string { + const hasGet = methods.includes('GET'); + const hasPatch = methods.includes('PATCH'); + const hasDelete = methods.includes('DELETE'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getAccessToken } from '@/lib/api-helpers'; +${handlerImport} +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; + +type RouteContext = { params: Promise<{ id: string }> }; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + headers: { Authorization: \`Bearer \${token}\` }, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasPatch) { + out += ` +export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + method: 'PATCH', + headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + if (hasDelete) { + out += ` +export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { + const token = await getAccessToken(req); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { + method: 'DELETE', + headers: { Authorization: \`Bearer \${token}\` }, + }); + if (res.status === 204) return new NextResponse(null, { status: 204 }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +}`)}; +`; + } + + return out; +} + +// ── Direct Mode Templates ─────────────────────────────────────────────────── + +function genSchemaFile(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const createFields = fields + .map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`) + .join('\n'); + const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n'); + + return `import { z } from 'zod'; + +export const Create${P}Schema = z.object({ +${createFields} +}); + +export const Update${P}Schema = z.object({ +${updateFields} +}); + +export type Create${P}Input = z.infer; +export type Update${P}Input = z.infer; +`; +} + +function genRepositoryFile(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n'); + + return `import { randomUUID } from 'node:crypto'; +import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore'; +import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}'; + +export interface ${P}Doc { + id: string; + productId: string; + userId: string; +${docFields} + createdAt: string; + updatedAt: string; +} + +function getContainer() { + return getCosmosContainer('${name}'); +} + +export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> { + const { resources } = await getContainer() + .items.query<${P}Doc>({ + query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim', + parameters: [ + { name: '@uid', value: userId }, + { name: '@pid', value: PRODUCT_ID }, + { name: '@off', value: offset }, + { name: '@lim', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> { + try { + const { resource } = await getContainer().item(id, userId).read<${P}Doc>(); + return resource ?? null; + } catch { + return null; + } +} + +export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> { + const now = new Date().toISOString(); + const doc: ${P}Doc = { + id: \`${name.slice(0, 3)}_\${randomUUID()}\`, + productId: PRODUCT_ID, + userId, + ...input, + createdAt: now, + updatedAt: now, + }; + await getContainer().items.create(doc); + return doc; +} + +export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> { + const existing = await get${P}(id, userId); + if (!existing) return null; + + const updated: ${P}Doc = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + await getContainer().item(id, userId).replace(updated); + return updated; +} + +export async function delete${P}(id: string, userId: string): Promise { + try { + await getContainer().item(id, userId).delete(); + return true; + } catch { + return false; + } +} +`; +} + +function genDirectListRoute( + name: string, + fields: ParsedField[], + methods: string[], + withHandler: boolean +): string { + const P = pascal(name); + const hasGet = methods.includes('GET'); + const hasPost = methods.includes('POST'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + const imports: string[] = []; + if (hasGet) imports.push(`list${P}`); + if (hasPost) imports.push(`create${P}`); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth-server'; +${handlerImport}`; + + if (imports.length > 0) { + out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; + } + + if (hasPost) { + out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`; + } + + if (hasGet) { + out += ` +export const GET = ${wrap(`async (req: NextRequest) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const url = new URL(req.url); + const limit = parseInt(url.searchParams.get('limit') ?? '50'); + const offset = parseInt(url.searchParams.get('offset') ?? '0'); + + const items = await list${P}(user.id, limit, offset); + return NextResponse.json({ items }); +}`)}; +`; + } + + if (hasPost) { + out += ` +export const POST = ${wrap(`async (req: NextRequest) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await req.json(); + const input = Create${P}Schema.parse(body); + const item = await create${P}(user.id, input); + return NextResponse.json(item, { status: 201 }); +}`)}; +`; + } + + return out; +} + +function genDirectDetailRoute( + name: string, + _fields: ParsedField[], + methods: string[], + withHandler: boolean +): string { + const P = pascal(name); + const hasGet = methods.includes('GET'); + const hasPatch = methods.includes('PATCH'); + const hasDelete = methods.includes('DELETE'); + const handlerImport = withHandler + ? `import { withErrorHandler } from '@/lib/api-handler';\n` + : ''; + const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); + + const imports: string[] = []; + if (hasGet) imports.push(`get${P}`); + if (hasPatch) imports.push(`update${P}`); + if (hasDelete) imports.push(`delete${P}`); + + let out = `import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth-server'; +${handlerImport}`; + + if (imports.length > 0) { + out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; + } + + if (hasPatch) { + out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`; + } + + out += ` +type RouteContext = { params: Promise<{ id: string }> }; +`; + + if (hasGet) { + out += ` +export const GET = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const item = await get${P}(id, user.id); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json(item); +}`)}; +`; + } + + if (hasPatch) { + out += ` +export const PATCH = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const body = await req.json(); + const updates = Update${P}Schema.parse(body); + const item = await update${P}(id, user.id, updates); + if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json(item); +}`)}; +`; + } + + if (hasDelete) { + out += ` +export const DELETE = ${wrap(`async ( + req: NextRequest, + { params }: RouteContext, +) => { + const user = await getCurrentUser(req.headers.get('authorization')); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id } = await params; + const deleted = await delete${P}(id, user.id); + if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + + return NextResponse.json({ success: true }); +}`)}; +`; + } + + return out; +} + +// ── Test Template ──────────────────────────────────────────────────────────── + +function genSchemaTest(name: string, fields: ParsedField[]): string { + const P = pascal(name); + const requiredFields = fields.filter(f => !f.optional); + + function sampleValue(f: ParsedField): string { + if (f.enumValues) return `'${f.enumValues[0]}'`; + if (f.tsType === 'string') return `'test'`; + if (f.tsType === 'number') return '42'; + if (f.tsType === 'boolean') return 'true'; + return `'2026-01-01T00:00:00.000Z'`; + } + + const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n'); + + return `import { describe, it, expect } from 'vitest'; +import { Create${P}Schema, Update${P}Schema } from './schemas/${name}'; + +describe('Create${P}Schema', () => { + it('accepts valid input', () => { + const result = Create${P}Schema.parse({ +${validPayload} + }); + expect(result).toBeDefined(); + }); + + it('rejects empty object', () => { + expect(() => Create${P}Schema.parse({})).toThrow(); + }); +}); + +describe('Update${P}Schema', () => { + it('accepts empty object (all optional)', () => { + const result = Update${P}Schema.parse({}); + expect(result).toEqual({}); + }); + + it('accepts partial update', () => { + const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} }); + expect(result).toBeDefined(); + }); +}); +`; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs(); + + console.log(`\n🚀 Generating API routes: ${name}`); + console.log(` Mode: ${mode}`); + console.log(` Methods: ${methods.join(', ')}`); + console.log(` Target: ${target}`); + if (fields) console.log(` Fields: ${fields}`); + if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); + + const parsedFields = parseFields(fields); + + // Determine which files to generate + const files: { path: string; content: string }[] = []; + + if (mode === 'proxy') { + files.push({ + path: `app/api/${name}/route.ts`, + content: genProxyListRoute(name, methods, withHandler), + }); + if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { + files.push({ + path: `app/api/${name}/[id]/route.ts`, + content: genProxyDetailRoute(name, methods, withHandler), + }); + } + } else { + // Direct mode — also generate schema + repository + if (parsedFields.length === 0) { + console.error('Error: --fields is required in direct mode'); + process.exit(1); + } + + files.push({ + path: `lib/schemas/${name}.ts`, + content: genSchemaFile(name, parsedFields), + }); + files.push({ + path: `lib/repositories/${name}.ts`, + content: genRepositoryFile(name, parsedFields), + }); + files.push({ + path: `app/api/${name}/route.ts`, + content: genDirectListRoute(name, parsedFields, methods, withHandler), + }); + if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { + files.push({ + path: `app/api/${name}/[id]/route.ts`, + content: genDirectDetailRoute(name, parsedFields, methods, withHandler), + }); + } + files.push({ + path: `lib/__tests__/${name}.test.ts`, + content: genSchemaTest(name, parsedFields), + }); + } + + if (dryRun) { + console.log('📄 Generated files:\n'); + for (const file of files) { + console.log(`── ${file.path} ──────────────────────────────────────`); + console.log(file.content); + } + console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); + return; + } + + // Write files + for (const file of files) { + const fullPath = path.join(target, file.path); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + + // Check if file already exists + try { + await fs.access(fullPath); + console.log(` ⚠️ SKIP ${file.path} (already exists)`); + continue; + } catch { + // Good — doesn't exist + } + + await fs.writeFile(fullPath, file.content, 'utf-8'); + console.log(` ✅ ${file.path}`); + } + + console.log(`\n✨ API routes generated for "${name}".`); + if (mode === 'direct') { + console.log(`\nPrerequisites (if not already present):`); + console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`); + console.log(` - lib/api-handler.ts — withErrorHandler HOF`); + console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`); + console.log(` - zod installed — npm install zod`); + } else { + console.log(`\nPrerequisites (if not already present):`); + console.log(` - lib/api-helpers.ts — getAccessToken(req) function`); + console.log(` - lib/api-handler.ts — withErrorHandler HOF`); + console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`); + } +} + +main().catch(err => { + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/vendor/bytelyst/create-app/src/index.ts b/vendor/bytelyst/create-app/src/index.ts new file mode 100644 index 0000000..48fc97d --- /dev/null +++ b/vendor/bytelyst/create-app/src/index.ts @@ -0,0 +1,9 @@ +/** + * @bytelyst/create-app — CLI tools for scaffolding ByteLyst product repos. + * + * Generators: + * - api-routes.ts — Next.js App Router API route generator + * - agents-md.ts — AGENTS.md auto-generator from product.json + repo scan + */ + +export {}; diff --git a/vendor/bytelyst/create-app/src/lib/template-engine.ts b/vendor/bytelyst/create-app/src/lib/template-engine.ts new file mode 100644 index 0000000..629f5e3 --- /dev/null +++ b/vendor/bytelyst/create-app/src/lib/template-engine.ts @@ -0,0 +1,34 @@ +/** + * Simple template engine for scaffolding. + * Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks. + */ + +export type TemplateVars = Record; + +/** + * Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks. + */ +export function renderTemplate(template: string, vars: TemplateVars): string { + let result = template; + + // Process conditional blocks: {{#IF KEY}}...{{/IF KEY}} + const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g; + result = result.replace(ifRegex, (_, key: string, content: string) => { + return vars[key] ? content : ''; + }); + + // Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}} + const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g; + result = result.replace(unlessRegex, (_, key: string, content: string) => { + return !vars[key] ? content : ''; + }); + + // Replace {{VARIABLE}} placeholders + result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { + const val = vars[key]; + if (val === undefined) return match; + return String(val); + }); + + return result; +} diff --git a/vendor/bytelyst/create-app/src/lib/templates.ts b/vendor/bytelyst/create-app/src/lib/templates.ts new file mode 100644 index 0000000..fd44728 --- /dev/null +++ b/vendor/bytelyst/create-app/src/lib/templates.ts @@ -0,0 +1,457 @@ +/** + * Inline templates for product repo scaffolding. + * Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax. + */ + +// ── product.json ───────────────────────────────────────────────────────────── + +export const PRODUCT_JSON = `{ + "productId": "{{PRODUCT_ID}}", + "displayName": "{{DISPLAY_NAME}}", + "tagline": "{{TAGLINE}}", + "domain": "{{DOMAIN}}", + "backendPort": {{BACKEND_PORT}}, + "primarySurface": "{{PRIMARY_SURFACE}}", + "bundleIds": { + "web": "{{DOMAIN}}"{{#IF HAS_IOS}}, + "ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}}, + "android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}} + }, + "appStore": { + "category": "Productivity", + "privacyUrl": "https://{{DOMAIN}}/privacy", + "termsUrl": "https://{{DOMAIN}}/terms", + "supportUrl": "https://{{DOMAIN}}/support" + }, + "version": "0.1.0" +} +`; + +// ── .gitignore ─────────────────────────────────────────────────────────────── + +export const GITIGNORE = `node_modules/ +dist/ +.next/ +.env +.env.local +*.log +.DS_Store +coverage/ +`; + +// ── .env.example ───────────────────────────────────────────────────────────── + +export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables +NODE_ENV=development +{{#IF HAS_BACKEND}} +# Backend +PORT={{BACKEND_PORT}} +HOST=0.0.0.0 +JWT_SECRET=dev-secret-change-me +DB_PROVIDER=memory +COSMOS_ENDPOINT= +COSMOS_KEY= +COSMOS_DATABASE=bytelyst +PLATFORM_SERVICE_URL=http://localhost:4003 +{{/IF HAS_BACKEND}} +{{#IF HAS_WEB}} +# Web +NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}} +{{/IF HAS_WEB}} +`; + +// ── README.md ──────────────────────────────────────────────────────────────── + +export const README = `# {{DISPLAY_NAME}} + +> {{TAGLINE}} + +## Quick Start + +\`\`\`bash +{{#IF HAS_BACKEND}}# Backend +cd backend && npm install && npm run dev +{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web +cd web && npm install && npm run dev +{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile +cd mobile && npm install && npm start +{{/IF HAS_MOBILE}}\`\`\` + +## Architecture + +| Layer | Technology | +|-------|-----------| +{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) | +{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 | +{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) | +{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) | +| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) | + +## Product Identity + +- **Product ID:** \`{{PRODUCT_ID}}\` +- **Domain:** {{DOMAIN}} +- **Backend Port:** {{BACKEND_PORT}} + +See [AGENTS.md](AGENTS.md) for AI agent instructions. +`; + +// ── Backend templates ──────────────────────────────────────────────────────── + +export const BACKEND_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", + "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", + "@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore", + "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", + "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", + "fastify": "^5.3.3", + "jose": "^6.0.11", + "zod": "^3.24.4" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} +`; + +export const BACKEND_TSCONFIG = `{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.ts"] +} +`; + +export const BACKEND_CONFIG = `import { z } from 'zod'; +import { PRODUCT_ID } from './product-config.js'; + +const envSchema = z.object({ + PORT: z.coerce.number().default({{BACKEND_PORT}}), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'), + COSMOS_ENDPOINT: z.string().optional(), + COSMOS_KEY: z.string().optional(), + COSMOS_DATABASE: z.string().default('bytelyst'), + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), + DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), + PRODUCT_ID: z.string().default(PRODUCT_ID), + PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), +}); + +export const config = envSchema.parse(process.env); +`; + +export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8'); +const manifest = JSON.parse(raw); +export const PRODUCT_ID: string = manifest.productId; +`; + +export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose'; +import type { FastifyRequest } from 'fastify'; +import { config } from './config.js'; + +const secret = new TextEncoder().encode(config.JWT_SECRET); + +export interface JwtPayload { + sub: string; + email?: string; + role?: string; +} + +export async function verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, secret); + return payload as unknown as JwtPayload; + } catch { + return null; + } +} + +export function extractToken(req: FastifyRequest): string | null { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return null; + return auth.slice(7); +} +`; + +export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify'; +import { verifyToken, extractToken, type JwtPayload } from './auth.js'; +import { config } from './config.js'; + +export async function getUserPayload(req: FastifyRequest): Promise { + const token = extractToken(req); + if (!token) return null; + return verifyToken(token); +} + +export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string { + if (!req.user?.sub) throw new Error('Unauthenticated'); + return req.user.sub; +} + +export function getRequestProductId(_req: FastifyRequest): string { + return config.PRODUCT_ID; +} +`; + +export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors'; +`; + +export const BACKEND_DATASTORE = `import { config } from './config.js'; + +const collections = new Map>(); + +export function getCollection(name: string): Map { + if (!collections.has(name)) { + collections.set(name, new Map()); + } + return collections.get(name) as Map; +} + +export const PRODUCT_ID = config.PRODUCT_ID; +export const DB_PROVIDER = config.DB_PROVIDER; +`; + +export const BACKEND_SERVER = `import Fastify from 'fastify'; +import { config } from './lib/config.js'; + +const app = Fastify({ + logger: { + level: config.NODE_ENV === 'test' ? 'silent' : 'info', + }, +}); + +// CORS +if (config.CORS_ORIGIN) { + app.addHook('onRequest', async (request, reply) => { + reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN); + reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + if (request.method === 'OPTIONS') { + reply.status(204).send(); + } + }); +} + +// Health check +app.get('/health', async () => ({ + status: 'ok', + service: config.SERVICE_NAME, + productId: config.PRODUCT_ID, +})); + +// TODO: Register your route modules here +// import { routes as exampleRoutes } from './modules/example/routes.js'; +// app.register(exampleRoutes, { prefix: '/api' }); + +async function start() { + try { + await app.listen({ port: config.PORT, host: config.HOST }); + app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +start(); + +export { app }; +`; + +// ── Web templates ──────────────────────────────────────────────────────────── + +export const WEB_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build --webpack", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.16.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.7.3" + } +} +`; + +export const WEB_TSCONFIG = `{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +`; + +export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; +`; + +export const WEB_LAYOUT = `import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: '{{DISPLAY_NAME}}', + description: '{{TAGLINE}}', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`; + +export const WEB_PAGE = `export default function Home() { + return ( +
+

{{DISPLAY_NAME}}

+

{{TAGLINE}}

+

Edit src/app/page.tsx to get started.

+
+ ); +} +`; + +export const WEB_PRODUCT_CONFIG = `const manifest = { + productId: '{{PRODUCT_ID}}', + displayName: '{{DISPLAY_NAME}}', + domain: '{{DOMAIN}}', + backendPort: {{BACKEND_PORT}}, +}; + +export const PRODUCT_ID = manifest.productId; + +export function getBackendURL(): string { + return process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:{{BACKEND_PORT}}'; +} + +export default manifest; +`; + +// ── Mobile (Expo) templates ────────────────────────────────────────────────── + +export const MOBILE_PACKAGE_JSON = `{ + "name": "@{{PRODUCT_ID}}/mobile", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "expo": "~55.0.0", + "expo-router": "~5.0.0", + "react": "^19.0.0", + "react-native": "^0.79.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "typescript": "^5.7.3" + } +} +`; + +export const MOBILE_APP_JSON = `{ + "expo": { + "name": "{{DISPLAY_NAME}}", + "slug": "{{PRODUCT_ID}}", + "version": "1.0.0", + "scheme": "{{PRODUCT_ID}}", + "platforms": ["ios", "android"], + "ios": { + "bundleIdentifier": "com.bytelyst.{{PRODUCT_ID}}" + }, + "android": { + "package": "com.{{PRODUCT_ID}}.app" + } + } +} +`; + +export const MOBILE_INDEX = `import { View, Text, StyleSheet } from 'react-native'; + +export default function Home() { + return ( + + {{DISPLAY_NAME}} + {{TAGLINE}} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, + title: { fontSize: 28, fontWeight: '700', marginBottom: 8 }, + subtitle: { fontSize: 16, color: '#666' }, +}); +`; diff --git a/vendor/bytelyst/create-app/src/scaffolder.ts b/vendor/bytelyst/create-app/src/scaffolder.ts new file mode 100644 index 0000000..e733506 --- /dev/null +++ b/vendor/bytelyst/create-app/src/scaffolder.ts @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** + * CLI Scaffolder — generates a fully wired ByteLyst product repo. + * + * Usage: + * npx tsx scaffolder.ts # Interactive prompts + * npx tsx scaffolder.ts --from product.json # From existing manifest + * npx tsx scaffolder.ts --from product.json --dry-run # Preview + * + * @module @bytelyst/create-app/scaffolder + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { renderTemplate, type TemplateVars } from './lib/template-engine.js'; +import * as T from './lib/templates.js'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ProductManifest { + productId: string; + displayName: string; + tagline: string; + domain: string; + backendPort: number; + primarySurface: string; + platforms: ('web' | 'mobile' | 'ios' | 'android')[]; + features: string[]; +} + +interface CliOptions { + from: string | null; + outDir: string | null; + dryRun: boolean; +} + +// ── CLI Arg Parsing ────────────────────────────────────────────────────────── + +function parseCliArgs(): CliOptions { + const args = process.argv.slice(2); + const options: CliOptions = { from: null, outDir: null, dryRun: false }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--from' || arg === '-f') options.from = args[++i]; + else if (arg === '--out' || arg === '-o') options.outDir = args[++i]; + else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; + else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +function showHelp(): void { + // eslint-disable-next-line no-console + console.log(` +@bytelyst/create-app — Product Repo Scaffolder + +Generates a fully wired ByteLyst product repo with backend, web, and/or mobile. + +Usage: + npx tsx scaffolder.ts # Interactive prompts + npx tsx scaffolder.ts --from product.json # From existing manifest + npx tsx scaffolder.ts --from product.json -d # Dry run + +Options: + --from, -f Path to existing product.json (skip prompts) + --out, -o Output directory (default: ./) + --dry-run, -d Preview files without writing + --help, -h Show this help + +Interactive Prompts: + 1. Product name + ID + tagline + domain + 2. Backend port + 3. Platform selection: web, mobile (Expo), iOS, Android + 4. Feature selection: auth, billing, telemetry, flags, sync, push + +Output: + / + ├── shared/product.json + ├── backend/ (if selected) + ├── web/ (if selected) + ├── mobile/ (if selected) + ├── .gitignore + ├── .env.example + ├── README.md + └── AGENTS.md +`); +} + +// ── Interactive Prompts ────────────────────────────────────────────────────── + +function createPrompt(): (question: string) => Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return (question: string) => + new Promise(resolve => { + rl.question(question, answer => { + resolve(answer.trim()); + }); + }); +} + +async function gatherManifestInteractively(): Promise { + const ask = createPrompt(); + + // eslint-disable-next-line no-console + console.log('\n📦 ByteLyst Product Scaffolder\n'); + + const displayName = (await ask('Product name (e.g., FlowMonk): ')) || 'MyApp'; + const defaultId = displayName.toLowerCase().replace(/[^a-z0-9]/g, ''); + const productId = (await ask(`Product ID [${defaultId}]: `)) || defaultId; + const tagline = (await ask('Tagline: ')) || `${displayName} — a ByteLyst product`; + const defaultDomain = `${productId}.app`; + const domain = (await ask(`Domain [${defaultDomain}]: `)) || defaultDomain; + const backendPort = parseInt(await ask('Backend port [4020]: ')) || 4020; + + // eslint-disable-next-line no-console + console.log('\nPlatforms (comma-separated: web, mobile, ios, android)'); + const platformStr = (await ask('Platforms [web]: ')) || 'web'; + const platforms = platformStr + .split(',') + .map(p => p.trim().toLowerCase()) as ProductManifest['platforms']; + + const primarySurface = platforms.includes('web') ? 'web' : platforms[0]; + + // eslint-disable-next-line no-console + console.log('\nFeatures (comma-separated: auth, billing, telemetry, flags, sync, push)'); + const featureStr = (await ask('Features [auth,telemetry,flags]: ')) || 'auth,telemetry,flags'; + const features = featureStr.split(',').map(f => f.trim().toLowerCase()); + + // Close readline + process.stdin.unref(); + + return { + productId, + displayName, + tagline, + domain, + backendPort, + primarySurface, + platforms, + features, + }; +} + +async function loadManifestFromFile(filePath: string): Promise { + const raw = await fs.readFile(filePath, 'utf-8'); + const data = JSON.parse(raw); + + return { + productId: data.productId || 'myapp', + displayName: data.displayName || data.productId || 'MyApp', + tagline: data.tagline || `${data.displayName} — a ByteLyst product`, + domain: data.domain || `${data.productId}.app`, + backendPort: data.backendPort || 4020, + primarySurface: data.primarySurface || 'web', + platforms: data.platforms || ['web'], + features: data.features || ['auth', 'telemetry', 'flags'], + }; +} + +// ── File Generator ─────────────────────────────────────────────────────────── + +interface GeneratedFile { + path: string; + content: string; +} + +function generateFiles(manifest: ProductManifest): GeneratedFile[] { + const vars: TemplateVars = { + PRODUCT_ID: manifest.productId, + DISPLAY_NAME: manifest.displayName, + TAGLINE: manifest.tagline, + DOMAIN: manifest.domain, + BACKEND_PORT: manifest.backendPort, + PRIMARY_SURFACE: manifest.primarySurface, + HAS_BACKEND: true, // Always generate backend + HAS_WEB: manifest.platforms.includes('web'), + HAS_MOBILE: manifest.platforms.includes('mobile'), + HAS_IOS: manifest.platforms.includes('ios'), + HAS_ANDROID: manifest.platforms.includes('android'), + HAS_AUTH: manifest.features.includes('auth'), + HAS_BILLING: manifest.features.includes('billing'), + HAS_TELEMETRY: manifest.features.includes('telemetry'), + HAS_FLAGS: manifest.features.includes('flags'), + HAS_SYNC: manifest.features.includes('sync'), + HAS_PUSH: manifest.features.includes('push'), + }; + + const render = (tmpl: string) => renderTemplate(tmpl, vars); + const files: GeneratedFile[] = []; + + // ── Root files + files.push({ path: 'shared/product.json', content: render(T.PRODUCT_JSON) }); + files.push({ path: '.gitignore', content: render(T.GITIGNORE) }); + files.push({ path: '.env.example', content: render(T.ENV_EXAMPLE) }); + files.push({ path: 'README.md', content: render(T.README) }); + + // ── Backend (always generated) + files.push({ path: 'backend/package.json', content: render(T.BACKEND_PACKAGE_JSON) }); + files.push({ path: 'backend/tsconfig.json', content: render(T.BACKEND_TSCONFIG) }); + files.push({ path: 'backend/src/lib/config.ts', content: render(T.BACKEND_CONFIG) }); + files.push({ + path: 'backend/src/lib/product-config.ts', + content: render(T.BACKEND_PRODUCT_CONFIG), + }); + files.push({ path: 'backend/src/lib/auth.ts', content: render(T.BACKEND_AUTH) }); + files.push({ + path: 'backend/src/lib/request-context.ts', + content: render(T.BACKEND_REQUEST_CONTEXT), + }); + files.push({ path: 'backend/src/lib/errors.ts', content: render(T.BACKEND_ERRORS) }); + files.push({ path: 'backend/src/lib/datastore.ts', content: render(T.BACKEND_DATASTORE) }); + files.push({ path: 'backend/src/server.ts', content: render(T.BACKEND_SERVER) }); + + // ── Web (if selected) + if (manifest.platforms.includes('web')) { + files.push({ path: 'web/package.json', content: render(T.WEB_PACKAGE_JSON) }); + files.push({ path: 'web/tsconfig.json', content: render(T.WEB_TSCONFIG) }); + files.push({ path: 'web/next.config.ts', content: render(T.WEB_NEXT_CONFIG) }); + files.push({ path: 'web/src/app/layout.tsx', content: render(T.WEB_LAYOUT) }); + files.push({ path: 'web/src/app/page.tsx', content: render(T.WEB_PAGE) }); + files.push({ path: 'web/src/lib/product-config.ts', content: render(T.WEB_PRODUCT_CONFIG) }); + } + + // ── Mobile (if selected) + if (manifest.platforms.includes('mobile')) { + files.push({ path: 'mobile/package.json', content: render(T.MOBILE_PACKAGE_JSON) }); + files.push({ path: 'mobile/app.json', content: render(T.MOBILE_APP_JSON) }); + files.push({ path: 'mobile/src/app/index.tsx', content: render(T.MOBILE_INDEX) }); + } + + return files; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const cliOpts = parseCliArgs(); + + const manifest = cliOpts.from + ? await loadManifestFromFile(cliOpts.from) + : await gatherManifestInteractively(); + + const outDir = cliOpts.outDir || manifest.productId; + const outPath = path.resolve(outDir); + + // eslint-disable-next-line no-console + console.log(`\n🚀 Scaffolding ${manifest.displayName}`); + // eslint-disable-next-line no-console + console.log(` Product ID: ${manifest.productId}`); + // eslint-disable-next-line no-console + console.log(` Output: ${outPath}`); + // eslint-disable-next-line no-console + console.log(` Platforms: ${manifest.platforms.join(', ')}`); + // eslint-disable-next-line no-console + console.log(` Features: ${manifest.features.join(', ')}`); + if (cliOpts.dryRun) { + // eslint-disable-next-line no-console + console.log(' ⚠️ DRY RUN\n'); + } + + const files = generateFiles(manifest); + + if (cliOpts.dryRun) { + // eslint-disable-next-line no-console + console.log(`📄 ${files.length} files would be generated:\n`); + for (const file of files) { + // eslint-disable-next-line no-console + console.log(`── ${file.path} ──────────────────────────────────────`); + // eslint-disable-next-line no-console + console.log(file.content); + } + // eslint-disable-next-line no-console + console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); + return; + } + + // Write files + for (const file of files) { + const fullPath = path.join(outPath, file.path); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, file.content, 'utf-8'); + // eslint-disable-next-line no-console + console.log(` ✅ ${file.path}`); + } + + // eslint-disable-next-line no-console + console.log(`\n✨ ${manifest.displayName} scaffolded at ${outPath}`); + // eslint-disable-next-line no-console + console.log(`\nNext steps:`); + // eslint-disable-next-line no-console + console.log(` cd ${outDir}/backend && npm install && npm run dev`); + if (manifest.platforms.includes('web')) { + // eslint-disable-next-line no-console + console.log(` cd ${outDir}/web && npm install && npm run dev`); + } +} + +// Export for testing +export { generateFiles, renderTemplate, type ProductManifest, type GeneratedFile }; + +main().catch(err => { + // eslint-disable-next-line no-console + console.error('❌ Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/vendor/bytelyst/create-app/tsconfig.json b/vendor/bytelyst/create-app/tsconfig.json new file mode 100644 index 0000000..81f2cd1 --- /dev/null +++ b/vendor/bytelyst/create-app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/create-app/vitest.config.ts b/vendor/bytelyst/create-app/vitest.config.ts new file mode 100644 index 0000000..19bef51 --- /dev/null +++ b/vendor/bytelyst/create-app/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/dashboard-components/package.json b/vendor/bytelyst/dashboard-components/package.json new file mode 100644 index 0000000..92002a2 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/package.json @@ -0,0 +1,39 @@ +{ + "name": "@bytelyst/dashboard-components", + "version": "0.1.5", + "description": "Shared React components for ByteLyst dashboards", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.3", + "vitest": "^4.0.18" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/dashboard-components/src/EmptyState.tsx b/vendor/bytelyst/dashboard-components/src/EmptyState.tsx new file mode 100644 index 0000000..f3aba1e --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/EmptyState.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; + +export interface EmptyStateProps { + icon?: ReactNode; + title: string; + description: string; + action?: { + label: string; + onClick: () => void; + }; + className?: string; +} + +export function EmptyState({ + icon, + title, + description, + action, + className = '', +}: EmptyStateProps): ReactNode { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

+ {title} +

+

+ {description} +

+ {action && ( + + )} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx b/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx new file mode 100644 index 0000000..ad12ef2 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/ErrorPage.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; + +export interface ErrorPageProps { + title?: string; + message?: string; + onRetry?: () => void; + className?: string; +} + +export function ErrorPage({ + title = 'Something went wrong', + message = 'An unexpected error occurred. Please try again.', + onRetry, + className = '', +}: ErrorPageProps): ReactNode { + return ( +
+
+ + + +
+

+ {title} +

+

+ {message} +

+ {onRetry && ( + + )} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx b/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx new file mode 100644 index 0000000..f7e2e91 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/LoadingSkeleton.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; + +export interface LoadingSkeletonProps { + rows?: number; + className?: string; +} + +export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx b/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx new file mode 100644 index 0000000..9109b2e --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/LoadingSpinner.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; + +export interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps): ReactNode { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-8 h-8', + lg: 'w-12 h-12', + }; + + return ( +
+ + + + +
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx b/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx new file mode 100644 index 0000000..ef2247f --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/NotFoundPage.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react'; + +export interface NotFoundPageProps { + title?: string; + message?: string; + statusCode?: string; + backLabel?: string; + backHref?: string; + onBack?: () => void; + className?: string; +} + +export function NotFoundPage({ + title = 'Page not found', + message = "The page you're looking for doesn't exist or has been moved.", + statusCode = '404', + backLabel = 'Go Back', + backHref, + onBack, + className = '', +}: NotFoundPageProps): ReactNode { + return ( +
+
+
+ {statusCode} +
+

+ {title} +

+

+ {message} +

+ {(onBack || backHref) && + (backHref ? ( + + {backLabel} + + ) : ( + + ))} +
+
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/PageHeader.tsx b/vendor/bytelyst/dashboard-components/src/PageHeader.tsx new file mode 100644 index 0000000..c8e5d1b --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/PageHeader.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +export interface Breadcrumb { + label: string; + href?: string; +} + +export interface PageHeaderProps { + title: string; + breadcrumbs?: Breadcrumb[]; + actions?: ReactNode; + className?: string; +} + +export function PageHeader({ + title, + breadcrumbs, + actions, + className = '', +}: PageHeaderProps): ReactNode { + return ( +
+
+ {breadcrumbs && breadcrumbs.length > 0 && ( + + )} +

+ {title} +

+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-components/src/components.test.tsx b/vendor/bytelyst/dashboard-components/src/components.test.tsx new file mode 100644 index 0000000..9f838e7 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/components.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LoadingSpinner } from './LoadingSpinner.js'; +import { LoadingSkeleton } from './LoadingSkeleton.js'; +import { EmptyState } from './EmptyState.js'; +import { PageHeader } from './PageHeader.js'; +import { ErrorPage } from './ErrorPage.js'; +import { NotFoundPage } from './NotFoundPage.js'; + +describe('LoadingSpinner', () => { + it('renders with default size', () => { + render(); + const status = screen.getByRole('status'); + expect(status).toBeDefined(); + expect(status.className).toContain('w-8 h-8'); + }); + + it('renders with small size', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('w-4 h-4'); + }); + + it('renders with large size', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('w-12 h-12'); + }); + + it('applies custom className', () => { + render(); + const status = screen.getByRole('status'); + expect(status.className).toContain('mt-4'); + }); + + it('renders SVG spinner element', () => { + render(); + const svg = screen.getByRole('status').querySelector('svg'); + expect(svg).toBeDefined(); + expect(svg!.classList.contains('animate-spin')).toBe(true); + }); +}); + +describe('LoadingSkeleton', () => { + it('renders default 3 rows', () => { + render(); + const container = screen.getByRole('status'); + const rows = container.querySelectorAll('.animate-pulse'); + expect(rows.length).toBe(3); + }); + + it('renders custom number of rows', () => { + render(); + const container = screen.getByRole('status'); + const rows = container.querySelectorAll('.animate-pulse'); + expect(rows.length).toBe(5); + }); + + it('applies custom className', () => { + render(); + const container = screen.getByRole('status'); + expect(container.className).toContain('my-8'); + }); + + it('renders pulse-animated skeleton rows', () => { + render(); + const row = screen.getByRole('status').querySelector('.animate-pulse'); + expect(row).toBeDefined(); + expect(row!.classList.contains('rounded')).toBe(true); + }); +}); + +describe('EmptyState', () => { + it('renders title and description', () => { + render(); + expect(screen.getByText('No items')).toBeDefined(); + expect(screen.getByText('Create your first item.')).toBeDefined(); + }); + + it('renders icon when provided', () => { + render( + X} + /> + ); + expect(screen.getByTestId('icon')).toBeDefined(); + }); + + it('does not render icon container when not provided', () => { + const { container } = render(); + const iconWrapper = container.querySelector('.w-16.h-16'); + expect(iconWrapper).toBeNull(); + }); + + it('renders action button and handles click', () => { + const onClick = vi.fn(); + render( + + ); + const button = screen.getByText('Create'); + expect(button).toBeDefined(); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('does not render action button when not provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + + it('renders with theme-aware structure', () => { + const { container } = render(); + const heading = container.querySelector('h3'); + expect(heading).toBeDefined(); + expect(heading!.textContent).toBe('Test'); + const desc = container.querySelector('p'); + expect(desc).toBeDefined(); + expect(desc!.textContent).toBe('Desc'); + }); +}); + +describe('PageHeader', () => { + it('renders title', () => { + render(); + expect(screen.getByText('Dashboard')).toBeDefined(); + }); + + it('renders breadcrumbs', () => { + render( + + ); + expect(screen.getByText('Home')).toBeDefined(); + expect(screen.getByLabelText('Breadcrumb')).toBeDefined(); + }); + + it('renders breadcrumb links with href', () => { + render( + + ); + const link = screen.getByText('Home'); + expect(link.tagName).toBe('A'); + expect(link.getAttribute('href')).toBe('/'); + }); + + it('renders breadcrumb text without href', () => { + render(); + const text = screen.getByText('Current'); + expect(text.tagName).toBe('SPAN'); + }); + + it('renders actions', () => { + render(Action} />); + expect(screen.getByTestId('action-btn')).toBeDefined(); + }); + + it('does not render breadcrumb nav when empty', () => { + const { container } = render(); + const nav = container.querySelector('nav'); + expect(nav).toBeNull(); + }); +}); + +describe('ErrorPage', () => { + it('renders with default props', () => { + render(); + expect(screen.getByText('Something went wrong')).toBeDefined(); + expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeDefined(); + }); + + it('renders custom title and message', () => { + render(); + expect(screen.getByText('Server Error')).toBeDefined(); + expect(screen.getByText('The server is down.')).toBeDefined(); + }); + + it('renders retry button and handles click', () => { + const onRetry = vi.fn(); + render(); + const button = screen.getByText('Try Again'); + expect(button).toBeDefined(); + fireEvent.click(button); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('does not render retry button when not provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + + it('renders error icon and semantic structure', () => { + const { container } = render(); + const iconContainer = container.querySelector('.w-16'); + expect(iconContainer).toBeDefined(); + const svg = iconContainer!.querySelector('svg'); + expect(svg).toBeDefined(); + const heading = container.querySelector('h2'); + expect(heading).toBeDefined(); + expect(heading!.textContent).toBe('Something went wrong'); + }); +}); + +describe('NotFoundPage', () => { + it('renders with default props', () => { + render(); + expect(screen.getByText('404')).toBeDefined(); + expect(screen.getByText('Page not found')).toBeDefined(); + }); + + it('renders custom status code', () => { + render(); + expect(screen.getByText('403')).toBeDefined(); + expect(screen.getByText('Forbidden')).toBeDefined(); + }); + + it('renders back button with onClick', () => { + const onBack = vi.fn(); + render(); + const button = screen.getByText('Go Back'); + fireEvent.click(button); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it('renders back link with href', () => { + render(); + const link = screen.getByText('Go Home'); + expect(link.tagName).toBe('A'); + expect(link.getAttribute('href')).toBe('/'); + }); + + it('does not render button when neither onBack nor backHref provided', () => { + const { container } = render(); + const buttons = container.querySelectorAll('button'); + const links = container.querySelectorAll('a'); + expect(buttons.length).toBe(0); + expect(links.length).toBe(0); + }); + + it('custom backLabel is used', () => { + render( {}} backLabel="Return" />); + expect(screen.getByText('Return')).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/dashboard-components/src/index.ts b/vendor/bytelyst/dashboard-components/src/index.ts new file mode 100644 index 0000000..ca49e77 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/src/index.ts @@ -0,0 +1,15 @@ +/** + * @bytelyst/dashboard-components + * + * Shared React components for ByteLyst dashboards. + * All components are theme-aware — they read CSS custom properties + * (--color-primary, --color-foreground, --color-muted, etc.) + * with sensible fallback defaults. + */ + +export { ErrorPage, type ErrorPageProps } from './ErrorPage.js'; +export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js'; +export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js'; +export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js'; +export { EmptyState, type EmptyStateProps } from './EmptyState.js'; +export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js'; diff --git a/vendor/bytelyst/dashboard-components/tsconfig.json b/vendor/bytelyst/dashboard-components/tsconfig.json new file mode 100644 index 0000000..b15ef2e --- /dev/null +++ b/vendor/bytelyst/dashboard-components/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/vendor/bytelyst/dashboard-components/vitest.config.ts b/vendor/bytelyst/dashboard-components/vitest.config.ts new file mode 100644 index 0000000..e8ecb58 --- /dev/null +++ b/vendor/bytelyst/dashboard-components/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + passWithNoTests: true, + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/dashboard-shell/package.json b/vendor/bytelyst/dashboard-shell/package.json new file mode 100644 index 0000000..c183f4f --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/package.json @@ -0,0 +1,39 @@ +{ + "name": "@bytelyst/dashboard-shell", + "version": "0.1.5", + "description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^18.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.7.3", + "vitest": "^4.0.18" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx b/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx new file mode 100644 index 0000000..b53c128 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/BillingPage.tsx @@ -0,0 +1,189 @@ +import type { ReactNode } from 'react'; +import type { BillingPageProps } from './types.js'; + +const statusColors: Record = { + active: 'var(--color-success, #16a34a)', + trialing: 'var(--color-warning, #d97706)', + past_due: 'var(--color-destructive, #dc2626)', + canceled: 'var(--color-muted-foreground, #6b7280)', +}; + +export function BillingPage({ + currentPlan = 'Free', + status = 'active', + trialEndsAt, + onManageBilling, + plans = [], +}: BillingPageProps): ReactNode { + return ( +
+

+ Billing +

+ + {/* Current plan card */} +
+
+
+
+ Current Plan +
+
+ {currentPlan} +
+
+ + {status.replace('_', ' ')} + +
+ + {trialEndsAt && ( +
+ Trial ends: {trialEndsAt} +
+ )} + + {onManageBilling && ( + + )} +
+ + {/* Plan comparison */} + {plans.length > 0 && ( +
+

+ Available Plans +

+
+ {plans.map(plan => ( +
+
{plan.name}
+
+ {plan.price} +
+
    + {plan.features.map(f => ( +
  • + ✓ {f} +
  • + ))} +
+ {plan.current && ( +
+ Current Plan +
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx b/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx new file mode 100644 index 0000000..d7457da --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/DashboardShell.tsx @@ -0,0 +1,73 @@ +import { useState, type ReactNode } from 'react'; +import type { DashboardShellProps } from './types.js'; +import { Sidebar } from './Sidebar.js'; +import { TopBar } from './TopBar.js'; + +export function DashboardShell({ + productName, + logo, + version, + nav, + pathname: externalPathname, + user, + features = {}, + onSignOut, + onNavigate, + sidebarFooter, + topBarActions, + children, +}: DashboardShellProps): ReactNode { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + // Use external pathname or default to '/' + const pathname = externalPathname ?? '/'; + + return ( +
+ {/* Sidebar */} + setSidebarCollapsed(!sidebarCollapsed)} + /> + + {/* Main content area */} +
+ {/* Top bar */} + + + {/* Page content */} +
+ {children} +
+
+
+ ); +} diff --git a/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx b/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx new file mode 100644 index 0000000..570bb31 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/ProfilePage.tsx @@ -0,0 +1,180 @@ +import { useState, type ReactNode } from 'react'; +import type { ProfilePageProps } from './types.js'; + +export function ProfilePage({ + user, + onUpdateProfile, + isLoading, + error, + success, +}: ProfilePageProps): ReactNode { + const [name, setName] = useState(user.name); + const [email, setEmail] = useState(user.email); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (onUpdateProfile) onUpdateProfile({ name, email }); + }; + + return ( +
+

+ Profile +

+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Avatar */} +
+
+ {user.avatarUrl ? ( + {user.name} + ) : ( + user.name + .split(' ') + .map(w => w[0]) + .join('') + .toUpperCase() + .slice(0, 2) + )} +
+
+
{user.name}
+
+ {user.email} +
+ {user.role && ( +
+ Role: {user.role} +
+ )} +
+
+ + {/* Form */} +
+
+ + setName(e.target.value)} + required + style={inputStyle} + /> +
+
+ + setEmail(e.target.value)} + required + style={inputStyle} + /> +
+ + {onUpdateProfile && ( + + )} +
+
+ ); +} + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: 14, + fontWeight: 500, + marginBottom: 6, + color: 'var(--color-foreground, #111827)', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))', + fontSize: 14, + background: 'var(--color-surface, #fff)', + color: 'var(--color-foreground, #111827)', + boxSizing: 'border-box', +}; + +function alertStyle(color: string): React.CSSProperties { + return { + padding: '10px 14px', + borderRadius: 8, + marginBottom: 16, + fontSize: 14, + color, + background: `color-mix(in srgb, ${color} 10%, transparent)`, + border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`, + }; +} diff --git a/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx b/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx new file mode 100644 index 0000000..2a0f669 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/SettingsPage.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import type { SettingsPageProps } from './types.js'; + +export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode { + return ( +
+

+ Settings +

+ + {sections.length === 0 && ( +
+ No settings configured for {productName}. +
+ )} + + {sections.map((section, i) => ( +
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} +
{section.content}
+
+ ))} +
+ ); +} diff --git a/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx b/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx new file mode 100644 index 0000000..4bc7743 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx @@ -0,0 +1,236 @@ +import type { ReactNode } from 'react'; +import type { SidebarProps, NavItem, NavSection } from './types.js'; + +function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] { + return nav.length > 0 && 'items' in nav[0]; +} + +function NavLink({ + item, + active, + collapsed, + onNavigate, +}: { + item: NavItem; + active: boolean; + collapsed: boolean; + onNavigate?: (href: string) => void; +}): ReactNode { + if (item.hidden) return null; + + const handleClick = (e: React.MouseEvent) => { + if (onNavigate) { + e.preventDefault(); + onNavigate(item.href); + } + }; + + return ( + + {item.icon && ( + + {item.icon} + + )} + {!collapsed && {item.label}} + {!collapsed && item.badge !== undefined && ( + + {item.badge} + + )} + + ); +} + +export function Sidebar({ + productName, + logo, + version, + nav, + pathname, + features = {}, + onNavigate, + footer, + collapsed = false, + onToggleCollapse, +}: SidebarProps): ReactNode { + const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }]; + + const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/'); + + // Add built-in settings nav if enabled and not already present + const hasSettings = features.settings !== false; + const allItems = sections.flatMap(s => s.items); + const settingsExists = allItems.some(i => i.href === '/settings'); + + return ( + + ); +} diff --git a/vendor/bytelyst/dashboard-shell/src/TopBar.tsx b/vendor/bytelyst/dashboard-shell/src/TopBar.tsx new file mode 100644 index 0000000..9759315 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/TopBar.tsx @@ -0,0 +1,244 @@ +import { useState, type ReactNode } from 'react'; +import type { TopBarProps } from './types.js'; + +export function TopBar({ + user, + features = {}, + onSignOut, + onNavigate, + actions, + onToggleSidebar, +}: TopBarProps): ReactNode { + const [menuOpen, setMenuOpen] = useState(false); + + const handleNav = (href: string) => { + setMenuOpen(false); + if (onNavigate) onNavigate(href); + }; + + const initials = user + ? user.name + .split(' ') + .map(w => w[0]) + .join('') + .toUpperCase() + .slice(0, 2) + : '?'; + + return ( +
+ {/* Left: mobile hamburger */} +
+ {onToggleSidebar && ( + + )} +
+ + {/* Right: actions + user menu */} +
+ {actions} + + {features.notifications && ( + + )} + + {user && ( +
+ + + {menuOpen && ( +
+
+
{user.name}
+
+ {user.email} +
+
+ + {features.profile !== false && ( + { + e.preventDefault(); + handleNav('/profile'); + }} + style={menuItemStyle} + > + Profile + + )} + {features.billing && ( + { + e.preventDefault(); + handleNav('/billing'); + }} + style={menuItemStyle} + > + Billing + + )} + {features.settings !== false && ( + { + e.preventDefault(); + handleNav('/settings'); + }} + style={menuItemStyle} + > + Settings + + )} + + {onSignOut && ( + + )} +
+ )} +
+ )} +
+
+ ); +} + +const menuItemStyle: React.CSSProperties = { + display: 'block', + padding: '10px 16px', + fontSize: 14, + color: 'var(--color-foreground, #111827)', + textDecoration: 'none', + cursor: 'pointer', +}; diff --git a/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx b/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx new file mode 100644 index 0000000..ebce925 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx @@ -0,0 +1,377 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { DashboardShell } from '../DashboardShell.js'; +import { Sidebar } from '../Sidebar.js'; +import { TopBar } from '../TopBar.js'; +import { ProfilePage } from '../ProfilePage.js'; +import { BillingPage } from '../BillingPage.js'; +import { SettingsPage } from '../SettingsPage.js'; +import type { NavItem, NavSection, ShellUser } from '../types.js'; + +const NAV: NavItem[] = [ + { href: '/dashboard', label: 'Dashboard', icon: '◈' }, + { href: '/tasks', label: 'Tasks', icon: '✓' }, + { href: '/settings', label: 'Settings', icon: '⚙' }, +]; + +const USER: ShellUser = { + id: 'u1', + name: 'Alice Smith', + email: 'alice@example.com', + role: 'admin', +}; + +// ── DashboardShell ─────────────────────────────────────────────────────────── + +describe('DashboardShell', () => { + beforeEach(() => cleanup()); + + it('renders sidebar, topbar, and content', () => { + render( + +
Page content
+
+ ); + expect(screen.getByTestId('bl-dashboard-shell')).toBeDefined(); + expect(screen.getByTestId('bl-shell-sidebar')).toBeDefined(); + expect(screen.getByTestId('bl-shell-topbar')).toBeDefined(); + expect(screen.getByTestId('bl-shell-main')).toBeDefined(); + expect(screen.getByText('Page content')).toBeDefined(); + }); + + it('passes product name to sidebar', () => { + render( + +
+ + ); + expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct'); + }); + + it('passes user to topbar', () => { + render( + +
+ + ); + expect(screen.getByText('Alice Smith')).toBeDefined(); + }); + + it('calls onSignOut when sign out clicked', () => { + const onSignOut = vi.fn(); + render( + +
+ + ); + // Open user menu + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); + expect(onSignOut).toHaveBeenCalledOnce(); + }); + + it('toggles sidebar collapse', () => { + render( + +
+ + ); + const toggle = screen.getByTestId('bl-shell-collapse-toggle'); + // Initially expanded — product name shows full text + expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('TestApp'); + fireEvent.click(toggle); + // Collapsed — shows first letter + expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('T'); + }); +}); + +// ── Sidebar ────────────────────────────────────────────────────────────────── + +describe('Sidebar', () => { + beforeEach(() => cleanup()); + + it('renders nav items', () => { + render(); + expect(screen.getByText('Dashboard')).toBeDefined(); + expect(screen.getByText('Tasks')).toBeDefined(); + }); + + it('highlights active nav item', () => { + render(); + const dashLink = screen.getByTestId('bl-nav-dashboard'); + expect(dashLink.style.fontWeight).toBe('600'); + }); + + it('calls onNavigate when item clicked', () => { + const onNavigate = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-nav-tasks')); + expect(onNavigate).toHaveBeenCalledWith('/tasks'); + }); + + it('supports NavSection format', () => { + const sections: NavSection[] = [ + { title: 'Main', items: [{ href: '/home', label: 'Home' }] }, + { title: 'Admin', items: [{ href: '/admin', label: 'Admin Panel' }] }, + ]; + render(); + expect(screen.getByText('Main')).toBeDefined(); + expect(screen.getByText('Admin')).toBeDefined(); + expect(screen.getByText('Home')).toBeDefined(); + expect(screen.getByText('Admin Panel')).toBeDefined(); + }); + + it('hides hidden nav items', () => { + const items: NavItem[] = [ + { href: '/visible', label: 'Visible' }, + { href: '/hidden', label: 'Hidden', hidden: true }, + ]; + render(); + expect(screen.getByText('Visible')).toBeDefined(); + expect(screen.queryByText('Hidden')).toBeNull(); + }); + + it('shows badge on nav item', () => { + const items: NavItem[] = [{ href: '/inbox', label: 'Inbox', badge: 5 }]; + render(); + expect(screen.getByText('5')).toBeDefined(); + }); + + it('renders version in footer', () => { + render(); + expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3'); + }); + + it('renders custom footer', () => { + render(Custom} />); + expect(screen.getByText('Custom')).toBeDefined(); + }); + + it('renders logo instead of product name', () => { + render( + Logo} + /> + ); + expect(screen.getByTestId('logo')).toBeDefined(); + }); + + it('auto-adds settings link when not present', () => { + const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }]; + render( + + ); + expect(screen.getByTestId('bl-nav-settings')).toBeDefined(); + }); + + it('does not duplicate settings link when already present', () => { + render(); + const settingsLinks = screen.getAllByText('Settings'); + expect(settingsLinks.length).toBe(1); + }); +}); + +// ── TopBar ─────────────────────────────────────────────────────────────────── + +describe('TopBar', () => { + beforeEach(() => cleanup()); + + it('renders user name', () => { + render(); + expect(screen.getByText('Alice Smith')).toBeDefined(); + }); + + it('renders initials avatar when no avatarUrl', () => { + render(); + expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS'); + }); + + it('opens and closes user menu', () => { + render(); + expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.getByTestId('bl-shell-user-menu')).toBeDefined(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); + }); + + it('shows profile link in menu by default', () => { + render(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.getByTestId('bl-shell-menu-profile')).toBeDefined(); + }); + + it('shows billing link when feature enabled', () => { + render(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.getByTestId('bl-shell-menu-billing')).toBeDefined(); + }); + + it('hides billing link when feature disabled', () => { + render(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull(); + }); + + it('shows notifications bell when enabled', () => { + render(); + expect(screen.getByTestId('bl-shell-notifications')).toBeDefined(); + }); + + it('calls onSignOut', () => { + const onSignOut = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); + expect(onSignOut).toHaveBeenCalledOnce(); + }); + + it('calls onNavigate for menu items', () => { + const onNavigate = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); + fireEvent.click(screen.getByTestId('bl-shell-menu-profile')); + expect(onNavigate).toHaveBeenCalledWith('/profile'); + }); + + it('renders custom actions', () => { + render(Action} />); + expect(screen.getByTestId('custom-action')).toBeDefined(); + }); + + it('shows hamburger when onToggleSidebar provided', () => { + const toggle = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-shell-hamburger')); + expect(toggle).toHaveBeenCalledOnce(); + }); +}); + +// ── ProfilePage ────────────────────────────────────────────────────────────── + +describe('ProfilePage', () => { + beforeEach(() => cleanup()); + + it('renders user info', () => { + render(); + expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined(); + expect(screen.getByTestId('bl-profile-avatar')).toBeDefined(); + }); + + it('pre-fills form fields', () => { + render(); + const nameInput = screen.getByTestId('bl-profile-name') as unknown as { value: string }; + const emailInput = screen.getByTestId('bl-profile-email') as unknown as { value: string }; + expect(nameInput.value).toBe('Alice Smith'); + expect(emailInput.value).toBe('alice@example.com'); + }); + + it('calls onUpdateProfile with form data', () => { + const onUpdate = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('bl-profile-name'), { target: { value: 'Bob Jones' } }); + fireEvent.submit(screen.getByTestId('bl-profile-submit').closest('form')!); + expect(onUpdate).toHaveBeenCalledWith({ name: 'Bob Jones', email: 'alice@example.com' }); + }); + + it('shows loading state', () => { + render(); + expect(screen.getByText('Saving...')).toBeDefined(); + }); + + it('shows error and success messages', () => { + const { rerender } = render(); + expect(screen.getByTestId('bl-profile-error')).toBeDefined(); + + rerender(); + expect(screen.getByTestId('bl-profile-success')).toBeDefined(); + }); + + it('shows role when present', () => { + render(); + expect(screen.getByText('Role: admin')).toBeDefined(); + }); +}); + +// ── BillingPage ────────────────────────────────────────────────────────────── + +describe('BillingPage', () => { + beforeEach(() => cleanup()); + + it('renders current plan', () => { + render(); + expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined(); + expect(screen.getByText('Pro')).toBeDefined(); + }); + + it('shows status badge', () => { + render(); + expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing'); + }); + + it('shows trial end date', () => { + render(); + expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01'); + }); + + it('calls onManageBilling', () => { + const onManage = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('bl-billing-manage')); + expect(onManage).toHaveBeenCalledOnce(); + }); + + it('renders plan comparison grid', () => { + const plans = [ + { name: 'Free', price: '$0/mo', features: ['Basic features'], current: true }, + { name: 'Pro', price: '$9/mo', features: ['All features', 'Priority support'] }, + ]; + render(); + expect(screen.getByTestId('bl-billing-plans')).toBeDefined(); + expect(screen.getByTestId('bl-billing-plan-free')).toBeDefined(); + expect(screen.getByTestId('bl-billing-plan-pro')).toBeDefined(); + expect(screen.getByText('$9/mo')).toBeDefined(); + }); + + it('defaults to Free plan when not specified', () => { + render(); + expect(screen.getByText('Free')).toBeDefined(); + }); +}); + +// ── SettingsPage ───────────────────────────────────────────────────────────── + +describe('SettingsPage', () => { + beforeEach(() => cleanup()); + + it('renders empty state when no sections', () => { + render(); + expect(screen.getByTestId('bl-shell-settings-page')).toBeDefined(); + expect(screen.getByTestId('bl-settings-empty')).toBeDefined(); + expect(screen.getByText('No settings configured for TestApp.')).toBeDefined(); + }); + + it('renders sections', () => { + const sections = [ + { title: 'Notifications', description: 'Manage alerts', content:
Toggle
}, + { title: 'Theme', content:
Dark mode
}, + ]; + render(); + expect(screen.getByTestId('bl-settings-section-0')).toBeDefined(); + expect(screen.getByTestId('bl-settings-section-1')).toBeDefined(); + expect(screen.getByText('Notifications')).toBeDefined(); + expect(screen.getByText('Manage alerts')).toBeDefined(); + expect(screen.getByText('Toggle')).toBeDefined(); + expect(screen.getByText('Dark mode')).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/dashboard-shell/src/index.ts b/vendor/bytelyst/dashboard-shell/src/index.ts new file mode 100644 index 0000000..70bef60 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/index.ts @@ -0,0 +1,30 @@ +/** + * @bytelyst/dashboard-shell + * + * Configurable Next.js dashboard layout with sidebar, top bar, + * and built-in pages for profile, billing, and settings. + * + * All components read CSS custom properties (--bl-shell-*, --color-*) + * with sensible fallback defaults. + */ + +export { DashboardShell } from './DashboardShell.js'; +export { Sidebar } from './Sidebar.js'; +export { TopBar } from './TopBar.js'; +export { ProfilePage } from './ProfilePage.js'; +export { BillingPage } from './BillingPage.js'; +export { SettingsPage } from './SettingsPage.js'; + +export type { + DashboardShellProps, + SidebarProps, + TopBarProps, + NavItem, + NavSection, + ShellUser, + ShellFeatures, + ProfilePageProps, + BillingPageProps, + SettingsPageProps, + SettingsSection, +} from './types.js'; diff --git a/vendor/bytelyst/dashboard-shell/src/types.ts b/vendor/bytelyst/dashboard-shell/src/types.ts new file mode 100644 index 0000000..14a3e98 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/src/types.ts @@ -0,0 +1,131 @@ +import type { ReactNode } from 'react'; + +// ── Navigation ─────────────────────────────────────────────────────────────── + +export interface NavItem { + href: string; + label: string; + icon?: ReactNode; + badge?: string | number; + /** Hide this item from nav (useful for feature-flag gating) */ + hidden?: boolean; +} + +export interface NavSection { + title?: string; + items: NavItem[]; +} + +// ── User ───────────────────────────────────────────────────────────────────── + +export interface ShellUser { + id: string; + name: string; + email: string; + avatarUrl?: string; + role?: string; +} + +// ── Features ───────────────────────────────────────────────────────────────── + +export interface ShellFeatures { + /** Show profile page link in user menu (default: true) */ + profile?: boolean; + /** Show billing page link in user menu (default: false) */ + billing?: boolean; + /** Show settings page link in sidebar (default: true) */ + settings?: boolean; + /** Show notifications icon in top bar (default: false) */ + notifications?: boolean; + /** Show dark/light theme toggle (default: false) */ + themeToggle?: boolean; +} + +// ── Shell Config ───────────────────────────────────────────────────────────── + +export interface DashboardShellProps { + /** Product display name shown in sidebar header */ + productName: string; + /** Product logo element (replaces text name if provided) */ + logo?: ReactNode; + /** Product version shown in sidebar footer */ + version?: string; + /** Navigation items or sections */ + nav: NavItem[] | NavSection[]; + /** Current pathname for active state (if not using internal detection) */ + pathname?: string; + /** Currently logged-in user */ + user?: ShellUser; + /** Feature toggles for built-in pages */ + features?: ShellFeatures; + /** Called when user clicks Sign Out */ + onSignOut?: () => void; + /** Called when a nav item is clicked (for SPA routers) */ + onNavigate?: (href: string) => void; + /** Sidebar footer content (replaces default) */ + sidebarFooter?: ReactNode; + /** Content to render in the top bar (right side) */ + topBarActions?: ReactNode; + /** Dashboard page content */ + children: ReactNode; +} + +// ── Sidebar Props ──────────────────────────────────────────────────────────── + +export interface SidebarProps { + productName: string; + logo?: ReactNode; + version?: string; + nav: NavItem[] | NavSection[]; + pathname: string; + features?: ShellFeatures; + onNavigate?: (href: string) => void; + footer?: ReactNode; + collapsed?: boolean; + onToggleCollapse?: () => void; +} + +// ── Top Bar Props ──────────────────────────────────────────────────────────── + +export interface TopBarProps { + user?: ShellUser; + features?: ShellFeatures; + onSignOut?: () => void; + onNavigate?: (href: string) => void; + actions?: ReactNode; + onToggleSidebar?: () => void; +} + +// ── Built-in Page Props ────────────────────────────────────────────────────── + +export interface ProfilePageProps { + user: ShellUser; + onUpdateProfile?: (data: { name: string; email: string }) => void; + isLoading?: boolean; + error?: string; + success?: string; +} + +export interface BillingPageProps { + currentPlan?: string; + status?: 'active' | 'trialing' | 'past_due' | 'canceled'; + trialEndsAt?: string; + onManageBilling?: () => void; + plans?: Array<{ + name: string; + price: string; + features: string[]; + current?: boolean; + }>; +} + +export interface SettingsPageProps { + productName: string; + sections?: SettingsSection[]; +} + +export interface SettingsSection { + title: string; + description?: string; + content: ReactNode; +} diff --git a/vendor/bytelyst/dashboard-shell/tsconfig.json b/vendor/bytelyst/dashboard-shell/tsconfig.json new file mode 100644 index 0000000..3128359 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.*"] +} diff --git a/vendor/bytelyst/dashboard-shell/vitest.config.ts b/vendor/bytelyst/dashboard-shell/vitest.config.ts new file mode 100644 index 0000000..cf32686 --- /dev/null +++ b/vendor/bytelyst/dashboard-shell/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/datastore/package.json b/vendor/bytelyst/datastore/package.json new file mode 100644 index 0000000..ed43c01 --- /dev/null +++ b/vendor/bytelyst/datastore/package.json @@ -0,0 +1,38 @@ +{ + "name": "@bytelyst/datastore", + "version": "0.1.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "peerDependencies": { + "@azure/cosmos": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@azure/cosmos": { + "optional": true + } + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/datastore/src/__tests__/memory.test.ts b/vendor/bytelyst/datastore/src/__tests__/memory.test.ts new file mode 100644 index 0000000..9652d2f --- /dev/null +++ b/vendor/bytelyst/datastore/src/__tests__/memory.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for MemoryDatastoreProvider and filter evaluation. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryDatastoreProvider } from '../providers/memory.js'; +import { matchesFilter, filterToCosmosSQL } from '../filter.js'; +import type { BaseDocument, DocumentCollection } from '../types.js'; + +interface TestDoc extends BaseDocument { + name: string; + price?: number; + tags?: string[]; + createdAt?: string; +} + +describe('MemoryDatastoreProvider', () => { + let provider: MemoryDatastoreProvider; + let collection: DocumentCollection; + + beforeEach(() => { + provider = new MemoryDatastoreProvider(); + collection = provider.getCollection('test', '/productId'); + }); + + it('isHealthy returns true', async () => { + expect(await provider.isHealthy()).toBe(true); + }); + + it('returns same collection instance for same name', () => { + const c1 = provider.getCollection('test', '/productId'); + const c2 = provider.getCollection('test', '/productId'); + expect(c1).toBe(c2); + }); + + describe('CRUD operations', () => { + const doc: TestDoc = { id: '1', productId: 'test', name: 'Widget', price: 10 }; + + it('create + findById', async () => { + const created = await collection.create(doc); + expect(created).toEqual(doc); + const found = await collection.findById('1', 'test'); + expect(found).toEqual(doc); + }); + + it('create throws on duplicate', async () => { + await collection.create(doc); + await expect(collection.create(doc)).rejects.toThrow('already exists'); + }); + + it('findById returns null for missing', async () => { + expect(await collection.findById('missing', 'test')).toBeNull(); + }); + + it('update merges fields', async () => { + await collection.create(doc); + const updated = await collection.update('1', 'test', { price: 20 }); + expect(updated.price).toBe(20); + expect(updated.name).toBe('Widget'); + }); + + it('update throws for missing doc', async () => { + await expect(collection.update('missing', 'test', { price: 20 })).rejects.toThrow( + 'not found' + ); + }); + + it('upsert creates or replaces', async () => { + const created = await collection.upsert(doc); + expect(created).toEqual(doc); + const replaced = await collection.upsert({ ...doc, name: 'Gadget' }); + expect(replaced.name).toBe('Gadget'); + }); + + it('delete removes doc', async () => { + await collection.create(doc); + await collection.delete('1', 'test'); + expect(await collection.findById('1', 'test')).toBeNull(); + }); + }); + + describe('findMany', () => { + const docs: TestDoc[] = [ + { id: '1', productId: 'p1', name: 'Alpha', price: 30, createdAt: '2026-01-01' }, + { id: '2', productId: 'p1', name: 'Beta', price: 10, createdAt: '2026-01-02' }, + { id: '3', productId: 'p1', name: 'Gamma', price: 20, createdAt: '2026-01-03' }, + { id: '4', productId: 'p2', name: 'Delta', price: 15, createdAt: '2026-01-04' }, + ]; + + beforeEach(async () => { + for (const d of docs) await collection.create(d); + }); + + it('returns all without query', async () => { + const results = await collection.findMany(); + expect(results).toHaveLength(4); + }); + + it('filters by exact match', async () => { + const results = await collection.findMany({ filter: { productId: 'p1' } }); + expect(results).toHaveLength(3); + }); + + it('filters with $gt', async () => { + const results = await collection.findMany({ filter: { price: { $gt: 15 } } }); + expect(results).toHaveLength(2); + }); + + it('filters with $in', async () => { + const results = await collection.findMany({ filter: { name: { $in: ['Alpha', 'Gamma'] } } }); + expect(results).toHaveLength(2); + }); + + it('sorts ascending', async () => { + const results = await collection.findMany({ sort: { price: 1 } }); + expect(results.map(d => d.price)).toEqual([10, 15, 20, 30]); + }); + + it('sorts descending', async () => { + const results = await collection.findMany({ sort: { price: -1 } }); + expect(results.map(d => d.price)).toEqual([30, 20, 15, 10]); + }); + + it('limits results', async () => { + const results = await collection.findMany({ limit: 2 }); + expect(results).toHaveLength(2); + }); + + it('offsets + limits', async () => { + const all = await collection.findMany({ sort: { price: 1 } }); + const page2 = await collection.findMany({ sort: { price: 1 }, offset: 2, limit: 2 }); + expect(page2).toEqual(all.slice(2, 4)); + }); + + it('selects specific fields', async () => { + const results = await collection.findMany({ select: ['id', 'name'] }); + expect(results[0]).toHaveProperty('id'); + expect(results[0]).toHaveProperty('name'); + expect(results[0]).not.toHaveProperty('price'); + }); + }); + + describe('findOne', () => { + it('returns first match', async () => { + await collection.create({ id: '1', productId: 'p', name: 'A' }); + await collection.create({ id: '2', productId: 'p', name: 'B' }); + const result = await collection.findOne({ filter: { productId: 'p' } }); + expect(result).not.toBeNull(); + }); + + it('returns null when no match', async () => { + const result = await collection.findOne({ filter: { productId: 'missing' } }); + expect(result).toBeNull(); + }); + }); + + describe('count', () => { + it('counts all without filter', async () => { + await collection.create({ id: '1', productId: 'p', name: 'A' }); + await collection.create({ id: '2', productId: 'p', name: 'B' }); + expect(await collection.count()).toBe(2); + }); + + it('counts with filter', async () => { + await collection.create({ id: '1', productId: 'p1', name: 'A' }); + await collection.create({ id: '2', productId: 'p2', name: 'B' }); + expect(await collection.count({ productId: 'p1' })).toBe(1); + }); + }); + + describe('aggregate', () => { + it('groups and aggregates', async () => { + await collection.create({ id: '1', productId: 'p1', name: 'A', price: 10 }); + await collection.create({ id: '2', productId: 'p1', name: 'B', price: 20 }); + await collection.create({ id: '3', productId: 'p2', name: 'C', price: 30 }); + + const results = await collection.aggregate<{ productId: string; total: number; cnt: number }>( + { + groupBy: 'productId', + aggregations: [ + { field: 'price', op: 'sum', alias: 'total' }, + { field: 'price', op: 'count', alias: 'cnt' }, + ], + } + ); + + expect(results).toHaveLength(2); + const p1 = results.find(r => r.productId === 'p1'); + expect(p1?.total).toBe(30); + expect(p1?.cnt).toBe(2); + }); + }); + + describe('rawQuery', () => { + it('throws for memory provider', async () => { + await expect(collection.rawQuery('SELECT * FROM c')).rejects.toThrow('not supported'); + }); + }); +}); + +describe('matchesFilter', () => { + const doc = { id: '1', productId: 'p', name: 'test', price: 25, tags: ['a', 'b'] }; + + it('exact match', () => { + expect(matchesFilter(doc, { name: 'test' })).toBe(true); + expect(matchesFilter(doc, { name: 'other' })).toBe(false); + }); + + it('$ne', () => { + expect(matchesFilter(doc, { price: { $ne: 30 } })).toBe(true); + expect(matchesFilter(doc, { price: { $ne: 25 } })).toBe(false); + }); + + it('$exists', () => { + expect(matchesFilter(doc, { price: { $exists: true } })).toBe(true); + expect(matchesFilter(doc, { missing: { $exists: false } })).toBe(true); + expect(matchesFilter(doc, { price: { $exists: false } })).toBe(false); + }); + + it('$startsWith', () => { + expect(matchesFilter(doc, { name: { $startsWith: 'te' } })).toBe(true); + expect(matchesFilter(doc, { name: { $startsWith: 'no' } })).toBe(false); + }); + + it('$contains on string', () => { + expect(matchesFilter(doc, { name: { $contains: 'es' } })).toBe(true); + }); + + it('$contains on array', () => { + expect(matchesFilter(doc, { tags: { $contains: 'a' } })).toBe(true); + expect(matchesFilter(doc, { tags: { $contains: 'z' } })).toBe(false); + }); + + it('$or', () => { + expect(matchesFilter(doc, { $or: [{ name: 'test' }, { name: 'other' }] })).toBe(true); + expect(matchesFilter(doc, { $or: [{ name: 'x' }, { name: 'y' }] })).toBe(false); + }); + + it('combined conditions', () => { + expect(matchesFilter(doc, { productId: 'p', price: { $gte: 20, $lt: 30 } })).toBe(true); + expect(matchesFilter(doc, { productId: 'p', price: { $gte: 30 } })).toBe(false); + }); +}); + +describe('filterToCosmosSQL', () => { + it('exact match', () => { + const result = filterToCosmosSQL({ name: 'test' }); + expect(result.query).toBe('WHERE c.name = @p0'); + expect(result.parameters).toEqual([{ name: '@p0', value: 'test' }]); + }); + + it('comparison operators', () => { + const result = filterToCosmosSQL({ price: { $gte: 10, $lt: 50 } }); + expect(result.query).toContain('c.price >= @p0'); + expect(result.query).toContain('c.price < @p1'); + }); + + it('$exists', () => { + const result = filterToCosmosSQL({ deleted: { $exists: false } }); + expect(result.query).toContain('NOT IS_DEFINED(c.deleted)'); + }); + + it('$in', () => { + const result = filterToCosmosSQL({ status: { $in: ['active', 'pending'] } }); + expect(result.query).toContain('c.status IN'); + expect(result.parameters).toHaveLength(2); + }); + + it('$contains emits ARRAY_CONTAINS OR CONTAINS', () => { + const result = filterToCosmosSQL({ tags: { $contains: 'urgent' } }); + expect(result.query).toContain('ARRAY_CONTAINS(c.tags, @p0)'); + expect(result.query).toContain('CONTAINS(c.tags, @p0)'); + expect(result.parameters).toEqual([{ name: '@p0', value: 'urgent' }]); + }); + + it('$or', () => { + const result = filterToCosmosSQL({ $or: [{ a: 1 }, { b: 2 }] }); + expect(result.query).toContain('OR'); + }); + + it('empty filter returns empty WHERE', () => { + const result = filterToCosmosSQL({}); + expect(result.query).toBe(''); + }); +}); diff --git a/vendor/bytelyst/datastore/src/factory.ts b/vendor/bytelyst/datastore/src/factory.ts new file mode 100644 index 0000000..3aa0ff6 --- /dev/null +++ b/vendor/bytelyst/datastore/src/factory.ts @@ -0,0 +1,59 @@ +/** + * Datastore provider factory. + * + * Creates a DatastoreProvider based on DB_PROVIDER env var or explicit type. + * Defaults to 'cosmos' for backward compatibility. + */ + +import { MemoryDatastoreProvider } from './providers/memory.js'; +import type { DatastoreProvider, DatastoreProviderType } from './types.js'; + +let _provider: DatastoreProvider | null = null; + +/** + * Get the singleton datastore provider. + * Lazily creates on first call based on DB_PROVIDER env var. + * + * - 'cosmos' (default) — Azure Cosmos DB + * - 'memory' — In-memory (for testing) + */ +export async function getDatastore(): Promise { + if (!_provider) { + const providerType = (process.env.DB_PROVIDER || 'cosmos') as DatastoreProviderType; + _provider = await createDatastoreProvider(providerType); + } + return _provider; +} + +/** + * Create a datastore provider by type. + */ +export async function createDatastoreProvider( + type: DatastoreProviderType +): Promise { + switch (type) { + case 'cosmos': { + const { CosmosDatastoreProvider } = await import('./providers/cosmos.js'); + return new CosmosDatastoreProvider(); + } + case 'memory': + return new MemoryDatastoreProvider(); + default: + throw new Error(`Unknown DB_PROVIDER: '${type}'. Valid: cosmos, memory`); + } +} + +/** + * Set the singleton datastore provider directly (for testing or manual wiring). + */ +export function setDatastore(provider: DatastoreProvider): void { + _provider = provider; +} + +/** + * Reset the singleton (for testing). + * @internal + */ +export function _resetDatastore(): void { + _provider = null; +} diff --git a/vendor/bytelyst/datastore/src/filter.ts b/vendor/bytelyst/datastore/src/filter.ts new file mode 100644 index 0000000..6ac78ae --- /dev/null +++ b/vendor/bytelyst/datastore/src/filter.ts @@ -0,0 +1,178 @@ +/** + * FilterMap evaluation utilities. + * + * Used by memory provider for in-memory filtering and + * by cosmos provider for SQL query generation. + */ + +import type { FilterMap, FilterOperator, FilterValue } from './types.js'; + +/** + * Evaluate a FilterMap against a document (in-memory). + * Returns true if the document matches all filter conditions. + */ +export function matchesFilter(doc: Record, filter: FilterMap): boolean { + for (const [key, condition] of Object.entries(filter)) { + if (key === '$or') { + const orClauses = condition as FilterMap[]; + if (!orClauses.some(clause => matchesFilter(doc, clause))) return false; + continue; + } + if (condition === undefined) continue; + + const value = getNestedValue(doc, key); + + if (isFilterOperator(condition)) { + if (!matchesOperator(value, condition as FilterOperator)) return false; + } else { + // Exact match + if (value !== condition) return false; + } + } + return true; +} + +function getNestedValue(obj: Record, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +function isFilterOperator(value: unknown): value is FilterOperator { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value); + return keys.some(k => k.startsWith('$')); +} + +function matchesOperator(docValue: unknown, op: FilterOperator): boolean { + if (op.$gt !== undefined && !(compare(docValue, op.$gt) > 0)) return false; + if (op.$gte !== undefined && !(compare(docValue, op.$gte) >= 0)) return false; + if (op.$lt !== undefined && !(compare(docValue, op.$lt) < 0)) return false; + if (op.$lte !== undefined && !(compare(docValue, op.$lte) <= 0)) return false; + if (op.$ne !== undefined && docValue === op.$ne) return false; + + if (op.$exists !== undefined) { + const exists = docValue !== undefined && docValue !== null; + if (op.$exists !== exists) return false; + } + + if (op.$startsWith !== undefined) { + if (typeof docValue !== 'string') return false; + if (!docValue.startsWith(op.$startsWith)) return false; + } + + if (op.$contains !== undefined) { + if (Array.isArray(docValue)) { + if (!docValue.includes(op.$contains)) return false; + } else if (typeof docValue === 'string' && typeof op.$contains === 'string') { + if (!docValue.includes(op.$contains)) return false; + } else { + return false; + } + } + + if (op.$in !== undefined) { + if (!op.$in.includes(docValue as FilterValue)) return false; + } + + return true; +} + +function compare(a: unknown, b: unknown): number { + if (a === b) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); + return String(a).localeCompare(String(b)); +} + +// ── Cosmos SQL generation ─────────────────────────────────────────────────── + +export interface SqlQuery { + query: string; + parameters: Array<{ name: string; value: unknown }>; +} + +/** + * Convert a FilterMap to a Cosmos SQL WHERE clause. + */ +export function filterToCosmosSQL(filter: FilterMap): SqlQuery { + const parameters: Array<{ name: string; value: unknown }> = []; + let paramIndex = 0; + + function nextParam(value: unknown): string { + const name = `@p${paramIndex++}`; + parameters.push({ name, value }); + return name; + } + + function buildCondition(key: string, condition: unknown): string { + if (condition === null) { + return `c.${key} = null`; + } + + if (!isFilterOperator(condition)) { + return `c.${key} = ${nextParam(condition)}`; + } + + const op = condition as FilterOperator; + const parts: string[] = []; + + if (op.$gt !== undefined) parts.push(`c.${key} > ${nextParam(op.$gt)}`); + if (op.$gte !== undefined) parts.push(`c.${key} >= ${nextParam(op.$gte)}`); + if (op.$lt !== undefined) parts.push(`c.${key} < ${nextParam(op.$lt)}`); + if (op.$lte !== undefined) parts.push(`c.${key} <= ${nextParam(op.$lte)}`); + if (op.$ne !== undefined) parts.push(`c.${key} != ${nextParam(op.$ne)}`); + + if (op.$exists !== undefined) { + parts.push(op.$exists ? `IS_DEFINED(c.${key})` : `NOT IS_DEFINED(c.${key})`); + } + + if (op.$startsWith !== undefined) { + parts.push(`STARTSWITH(c.${key}, ${nextParam(op.$startsWith)})`); + } + + if (op.$contains !== undefined) { + // Could be array or string — emit both ARRAY_CONTAINS and CONTAINS. + // Cosmos returns false (not error) when the function doesn't match the field type, + // so OR-ing them handles both array membership and string substring correctly. + const param = nextParam(op.$contains); + parts.push(`(ARRAY_CONTAINS(c.${key}, ${param}) OR CONTAINS(c.${key}, ${param}))`); + } + + if (op.$in !== undefined) { + const placeholders = op.$in.map(v => nextParam(v)); + parts.push(`c.${key} IN (${placeholders.join(', ')})`); + } + + return parts.join(' AND '); + } + + function buildFilter(f: FilterMap): string { + const clauses: string[] = []; + + for (const [key, condition] of Object.entries(f)) { + if (key === '$or') { + const orClauses = (condition as FilterMap[]).map(c => `(${buildFilter(c)})`); + clauses.push(`(${orClauses.join(' OR ')})`); + continue; + } + if (condition === undefined) continue; + clauses.push(buildCondition(key, condition)); + } + + return clauses.join(' AND '); + } + + const where = buildFilter(filter); + return { + query: where ? `WHERE ${where}` : '', + parameters, + }; +} diff --git a/vendor/bytelyst/datastore/src/index.ts b/vendor/bytelyst/datastore/src/index.ts new file mode 100644 index 0000000..e0718b8 --- /dev/null +++ b/vendor/bytelyst/datastore/src/index.ts @@ -0,0 +1,18 @@ +export type { + BaseDocument, + FilterMap, + FilterValue, + FilterOperator, + FilterCondition, + CollectionQuery, + AggregateQuery, + AggregationField, + DocumentCollection, + DatastoreProvider, + DatastoreProviderType, +} from './types.js'; + +export { getDatastore, createDatastoreProvider, setDatastore, _resetDatastore } from './factory.js'; +export { CosmosDatastoreProvider, type CosmosProviderConfig } from './providers/cosmos.js'; +export { MemoryDatastoreProvider } from './providers/memory.js'; +export { matchesFilter, filterToCosmosSQL } from './filter.js'; diff --git a/vendor/bytelyst/datastore/src/providers/cosmos.ts b/vendor/bytelyst/datastore/src/providers/cosmos.ts new file mode 100644 index 0000000..059f9d8 --- /dev/null +++ b/vendor/bytelyst/datastore/src/providers/cosmos.ts @@ -0,0 +1,243 @@ +/** + * Azure Cosmos DB datastore provider. + * + * Wraps @azure/cosmos SDK behind the cloud-agnostic DocumentCollection interface. + * Translates FilterMap queries to Cosmos SQL. + */ + +import { filterToCosmosSQL } from '../filter.js'; +import type { + AggregateQuery, + BaseDocument, + CollectionQuery, + DatastoreProvider, + DocumentCollection, + FilterMap, +} from '../types.js'; + +export interface CosmosProviderConfig { + endpoint: string; + key: string; + database: string; +} + +export class CosmosDatastoreProvider implements DatastoreProvider { + private client: import('@azure/cosmos').CosmosClient | null = null; + private databaseRef: import('@azure/cosmos').Database | null = null; + private config: CosmosProviderConfig; + private collections = new Map>(); + + constructor(config?: CosmosProviderConfig) { + this.config = config ?? { + endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), + key: getEnvOrThrow('COSMOS_KEY'), + database: process.env.COSMOS_DATABASE || 'lysnrai', + }; + } + + private async getDatabase() { + if (!this.databaseRef) { + const { CosmosClient } = await import('@azure/cosmos'); + this.client = new CosmosClient({ + endpoint: this.config.endpoint, + key: this.config.key, + }); + this.databaseRef = this.client.database(this.config.database); + } + return this.databaseRef as import('@azure/cosmos').Database; + } + + getCollection( + name: string, + partitionKeyPath: string + ): DocumentCollection { + const cacheKey = `${name}:${partitionKeyPath}`; + let collection = this.collections.get(cacheKey); + if (!collection) { + collection = new CosmosCollection(name, partitionKeyPath, () => + this.getDatabase() + ); + this.collections.set(cacheKey, collection); + } + return collection as unknown as DocumentCollection; + } + + async isHealthy(): Promise { + try { + const db = await this.getDatabase(); + await db.read(); + return true; + } catch { + return false; + } + } +} + +class CosmosCollection implements DocumentCollection { + constructor( + private containerName: string, + private partitionKeyPath: string, + private getDatabase: () => Promise + ) {} + + private async container() { + const db = await this.getDatabase(); + return db.container(this.containerName); + } + + private pkField(): string { + // Convert /userId to userId, /id to id, etc. + return this.partitionKeyPath.replace(/^\//, ''); + } + + async findById(id: string, partitionKey: string): Promise { + try { + const c = await this.container(); + const { resource } = await c.item(id, partitionKey).read(); + return resource ?? null; + } catch (err: unknown) { + if ((err as { code?: number })?.code === 404) return null; + throw err; + } + } + + async findMany(query?: CollectionQuery): Promise { + const c = await this.container(); + const sql = this.buildSelectSQL(query); + const { resources } = await c.items + .query({ + query: sql.query, + parameters: sql.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + async findOne(query?: CollectionQuery): Promise { + const results = await this.findMany({ ...query, limit: 1 }); + return results[0] ?? null; + } + + async count(filter?: FilterMap): Promise { + const c = await this.container(); + const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] }; + const { resources } = await c.items + .query({ + query: `SELECT VALUE COUNT(1) FROM c ${whereClause.query}`, + parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources[0] ?? 0; + } + + async create(doc: T): Promise { + const c = await this.container(); + const { resource } = await c.items.create(doc); + return resource as T; + } + + async update(id: string, partitionKey: string, updates: Partial): Promise { + const c = await this.container(); + const { resource: existing } = await c.item(id, partitionKey).read(); + if (!existing) throw new Error(`Document '${id}' not found`); + const merged = { ...existing, ...updates } as T; + const { resource } = await c.item(id, partitionKey).replace(merged); + return resource as T; + } + + async upsert(doc: T): Promise { + const c = await this.container(); + const { resource } = await c.items.upsert(doc); + return resource as T; + } + + async delete(id: string, partitionKey: string): Promise { + const c = await this.container(); + await c.item(id, partitionKey).delete(); + } + + async aggregate>(query: AggregateQuery): Promise { + const c = await this.container(); + const whereClause = query.filter + ? filterToCosmosSQL(query.filter) + : { query: '', parameters: [] }; + + const aggFields = query.aggregations + .map(a => { + const func = a.op === 'count' ? `COUNT(1)` : `${a.op.toUpperCase()}(c.${a.field})`; + return `${func} AS ${a.alias}`; + }) + .join(', '); + + const { resources } = await c.items + .query({ + query: `SELECT c.${query.groupBy}, ${aggFields} FROM c ${whereClause.query} GROUP BY c.${query.groupBy}`, + parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + async rawQuery(query: string, parameters?: Record): Promise { + const c = await this.container(); + const cosmosParams = parameters + ? Object.entries(parameters).map(([name, value]) => ({ + name: name.startsWith('@') ? name : `@${name}`, + value, + })) + : []; + const { resources } = await c.items + .query({ + query, + parameters: cosmosParams as import('@azure/cosmos').SqlParameter[], + }) + .fetchAll(); + return resources; + } + + private buildSelectSQL(query?: CollectionQuery): { + query: string; + parameters: Array<{ name: string; value: unknown }>; + } { + const parts: string[] = []; + + // SELECT + if (query?.select && query.select.length > 0) { + const fields = query.select.map(f => `c.${f as string}`).join(', '); + parts.push(`SELECT ${fields} FROM c`); + } else { + parts.push('SELECT * FROM c'); + } + + // WHERE + const whereClause = query?.filter + ? filterToCosmosSQL(query.filter) + : { query: '', parameters: [] }; + const parameters = [...whereClause.parameters]; + if (whereClause.query) parts.push(whereClause.query); + + // ORDER BY + if (query?.sort) { + const orderParts = Object.entries(query.sort) + .map(([field, dir]) => `c.${field} ${dir === 1 ? 'ASC' : 'DESC'}`) + .join(', '); + if (orderParts) parts.push(`ORDER BY ${orderParts}`); + } + + // OFFSET / LIMIT + if (query?.offset !== undefined && query?.limit !== undefined) { + parts.push(`OFFSET ${query.offset} LIMIT ${query.limit}`); + } else if (query?.limit !== undefined) { + parts.push(`OFFSET 0 LIMIT ${query.limit}`); + } + + return { query: parts.join(' '), parameters }; + } +} + +function getEnvOrThrow(name: string): string { + const value = process.env[name]; + if (!value) + throw new Error(`Environment variable ${name} is required for CosmosDatastoreProvider`); + return value; +} diff --git a/vendor/bytelyst/datastore/src/providers/memory.ts b/vendor/bytelyst/datastore/src/providers/memory.ts new file mode 100644 index 0000000..9d1cc3f --- /dev/null +++ b/vendor/bytelyst/datastore/src/providers/memory.ts @@ -0,0 +1,202 @@ +/** + * In-memory datastore provider — for testing and local dev. + * + * All data is stored in Maps. No persistence. Fast and deterministic. + */ + +import { matchesFilter } from '../filter.js'; +import type { + AggregateQuery, + BaseDocument, + CollectionQuery, + DatastoreProvider, + DocumentCollection, + FilterMap, +} from '../types.js'; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; +} + +export class MemoryDatastoreProvider implements DatastoreProvider { + private collections = new Map>(); + + getCollection( + name: string, + _partitionKeyPath: string + ): DocumentCollection { + let collection = this.collections.get(name); + if (!collection) { + collection = new MemoryCollection(); + this.collections.set(name, collection); + } + return collection as unknown as DocumentCollection; + } + + async isHealthy(): Promise { + return true; + } + + /** Clear all collections (for test cleanup). */ + clear(): void { + this.collections.clear(); + } +} + +class MemoryCollection implements DocumentCollection { + private docs = new Map(); + + async findById(id: string, _partitionKey: string): Promise { + return this.docs.get(id) ?? null; + } + + async findMany(query?: CollectionQuery): Promise { + let results = [...this.docs.values()]; + + if (query?.filter) { + results = results.filter(doc => matchesFilter(doc as Record, query.filter!)); + } + + if (query?.sort) { + const sortEntries = Object.entries(query.sort) as Array<[string, 1 | -1]>; + results.sort((a, b) => { + for (const [field, dir] of sortEntries) { + const aVal = (a as Record)[field]; + const bVal = (b as Record)[field]; + const cmp = compareValues(aVal, bVal); + if (cmp !== 0) return cmp * dir; + } + return 0; + }); + } + + if (query?.offset) { + results = results.slice(query.offset); + } + + if (query?.limit) { + results = results.slice(0, query.limit); + } + + if (query?.select && query.select.length > 0) { + results = results.map(doc => { + const picked: Record = {}; + for (const key of query.select!) { + picked[key as string] = (doc as Record)[key as string]; + } + return picked as T; + }); + } + + return results; + } + + async findOne(query?: CollectionQuery): Promise { + const results = await this.findMany({ ...query, limit: 1 }); + return results[0] ?? null; + } + + async count(filter?: FilterMap): Promise { + if (!filter) return this.docs.size; + const results = [...this.docs.values()].filter(doc => + matchesFilter(doc as Record, filter) + ); + return results.length; + } + + async create(doc: T): Promise { + if (this.docs.has(doc.id)) { + throw new Error(`Document with id '${doc.id}' already exists`); + } + const clone = deepClone(doc); + this.docs.set(doc.id, clone); + return clone; + } + + async update(id: string, _partitionKey: string, updates: Partial): Promise { + const existing = this.docs.get(id); + if (!existing) { + throw new Error(`Document with id '${id}' not found`); + } + const merged = { ...existing, ...updates } as T; + this.docs.set(id, deepClone(merged)); + return deepClone(merged); + } + + async upsert(doc: T): Promise { + const clone = deepClone(doc); + this.docs.set(doc.id, clone); + return clone; + } + + async delete(id: string, _partitionKey: string): Promise { + this.docs.delete(id); + } + + async aggregate>(query: AggregateQuery): Promise { + let docs = [...this.docs.values()]; + if (query.filter) { + docs = docs.filter(doc => matchesFilter(doc as Record, query.filter!)); + } + + // Group by field + const groups = new Map(); + for (const doc of docs) { + const key = String((doc as Record)[query.groupBy] ?? '__null__'); + const group = groups.get(key) ?? []; + group.push(doc); + groups.set(key, group); + } + + const results: Record[] = []; + for (const [groupKey, groupDocs] of groups) { + const row: Record = { [query.groupBy]: groupKey }; + for (const agg of query.aggregations) { + row[agg.alias] = computeAggregation(groupDocs, agg.field, agg.op); + } + results.push(row); + } + + return results as R[]; + } + + async rawQuery(_query: string, _parameters?: Record): Promise { + throw new Error( + 'rawQuery is not supported by MemoryDatastoreProvider. Use findMany/aggregate instead.' + ); + } +} + +function compareValues(a: unknown, b: unknown): number { + if (a === b) return 0; + if (a == null) return -1; + if (b == null) return 1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + return String(a).localeCompare(String(b)); +} + +function computeAggregation( + docs: T[], + field: string, + op: 'count' | 'sum' | 'avg' | 'min' | 'max' +): number { + if (op === 'count') return docs.length; + + const values = docs + .map(d => (d as Record)[field]) + .filter((v): v is number => typeof v === 'number'); + + if (values.length === 0) return 0; + + switch (op) { + case 'sum': + return values.reduce((a, b) => a + b, 0); + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + } +} diff --git a/vendor/bytelyst/datastore/src/testing.ts b/vendor/bytelyst/datastore/src/testing.ts new file mode 100644 index 0000000..6466bbf --- /dev/null +++ b/vendor/bytelyst/datastore/src/testing.ts @@ -0,0 +1,53 @@ +/** + * Test helpers for @bytelyst/datastore. + * + * Use setTestProvider('memory') in beforeAll() to wire up + * a fast, deterministic in-memory provider for tests. + */ + +import { setDatastore, _resetDatastore } from './factory.js'; +import { MemoryDatastoreProvider } from './providers/memory.js'; +import type { BaseDocument, DocumentCollection, DatastoreProviderType } from './types.js'; + +let _testProvider: MemoryDatastoreProvider | null = null; + +/** + * Set a test provider. Call in beforeAll(). + * Currently only 'memory' is supported for testing. + */ +export function setTestProvider(type: DatastoreProviderType = 'memory'): MemoryDatastoreProvider { + if (type !== 'memory') { + throw new Error(`setTestProvider only supports 'memory', got '${type}'`); + } + _testProvider = new MemoryDatastoreProvider(); + setDatastore(_testProvider); + return _testProvider; +} + +/** + * Clear all test data. Call in afterEach() or afterAll(). + */ +export function clearTestData(): void { + _testProvider?.clear(); +} + +/** + * Reset the test provider. Call in afterAll(). + */ +export function resetTestProvider(): void { + _testProvider?.clear(); + _testProvider = null; + _resetDatastore(); +} + +/** + * Seed a collection with documents for testing. + */ +export async function seedCollection( + collection: DocumentCollection, + docs: T[] +): Promise { + for (const doc of docs) { + await collection.create(doc); + } +} diff --git a/vendor/bytelyst/datastore/src/types.ts b/vendor/bytelyst/datastore/src/types.ts new file mode 100644 index 0000000..02fd04e --- /dev/null +++ b/vendor/bytelyst/datastore/src/types.ts @@ -0,0 +1,112 @@ +/** + * Cloud-agnostic datastore interfaces. + * + * Provides DocumentCollection and DatastoreProvider abstractions + * that work with Cosmos DB, MongoDB, or in-memory storage. + */ + +// ── Base document type ────────────────────────────────────────────────────── + +export interface BaseDocument { + id: string; + productId: string; +} + +// ── Filter operators ──────────────────────────────────────────────────────── + +export type FilterValue = string | number | boolean | null; + +export interface FilterOperator { + $gt?: FilterValue; + $gte?: FilterValue; + $lt?: FilterValue; + $lte?: FilterValue; + $ne?: FilterValue; + $exists?: boolean; + $startsWith?: string; + $contains?: FilterValue; + $in?: FilterValue[]; +} + +export type FilterCondition = FilterValue | FilterOperator; + +export interface FilterMap { + [field: string]: FilterCondition | FilterMap[] | undefined; + $or?: FilterMap[]; +} + +// ── Query types ───────────────────────────────────────────────────────────── + +export interface CollectionQuery { + filter?: FilterMap; + sort?: Partial>; + limit?: number; + offset?: number; + select?: (keyof T & string)[]; +} + +export interface AggregateQuery { + groupBy: string; + aggregations: AggregationField[]; + filter?: FilterMap; +} + +export interface AggregationField { + field: string; + op: 'count' | 'sum' | 'avg' | 'min' | 'max'; + alias: string; +} + +// ── Document collection interface ─────────────────────────────────────────── + +export interface DocumentCollection { + /** Find a single document by ID + partition key. */ + findById(id: string, partitionKey: string): Promise; + + /** Find multiple documents matching a query. */ + findMany(query?: CollectionQuery): Promise; + + /** Find the first document matching a query. */ + findOne(query?: CollectionQuery): Promise; + + /** Count documents matching a filter. */ + count(filter?: FilterMap): Promise; + + /** Create a new document. */ + create(doc: T): Promise; + + /** Update a document by ID + partition key (merge semantics). */ + update(id: string, partitionKey: string, updates: Partial): Promise; + + /** Upsert a document (create or replace). */ + upsert(doc: T): Promise; + + /** Delete a document by ID + partition key. */ + delete(id: string, partitionKey: string): Promise; + + /** Run an aggregation query. */ + aggregate>(query: AggregateQuery): Promise; + + /** + * Execute a raw provider-specific query (escape hatch). + * Returns results as-is from the underlying provider. + */ + rawQuery(query: string, parameters?: Record): Promise; +} + +// ── Datastore provider interface ──────────────────────────────────────────── + +export interface DatastoreProvider { + /** Get a collection by name with a partition key path. */ + getCollection( + name: string, + partitionKeyPath: string + ): DocumentCollection; + + /** Check if the datastore is healthy / reachable. */ + isHealthy(): Promise; +} + +// ── Provider config ───────────────────────────────────────────────────────── + +export type DatastoreProviderType = 'cosmos' | 'memory'; diff --git a/vendor/bytelyst/datastore/tsconfig.json b/vendor/bytelyst/datastore/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/datastore/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/design-tokens/README.md b/vendor/bytelyst/design-tokens/README.md new file mode 100644 index 0000000..3e33d52 --- /dev/null +++ b/vendor/bytelyst/design-tokens/README.md @@ -0,0 +1,276 @@ +# @bytelyst/design-tokens + +ByteLyst cross-platform design system tokens. Single source of truth for colors, typography, spacing, and more across Web, iOS, Android/KMP, and React Native. + +## Quick Start + +### Install + +```bash +# This is a workspace package - use from source +# Copy generated files to your project +``` + +### Generate Tokens + +```bash +# From the monorepo root +pnpm --filter @bytelyst/design-tokens generate +``` + +This regenerates all platform outputs from `tokens/bytelyst.tokens.json`. + +--- + +## Token Structure + +### Color Tokens + +| Token Path | Example Value | Usage | +| ----------------------------------- | ------------- | -------------------------- | +| `color.semantic.dark.bgCanvas` | `#06070A` | Dark mode background | +| `color.semantic.dark.accentPrimary` | `#5A8CFF` | Primary brand color | +| `color.semantic.light.surfaceCard` | `#FFFFFF` | Light mode card background | +| `color.nomgap.stageKetosis` | `#5A8CFF` | Product-specific color | + +### Typography Tokens + +| Token Path | Value | CSS Output | +| -------------------------------- | ----------------- | ------------------- | +| `typography.fontFamily.display` | `'Space Grotesk'` | `--ml-font-display` | +| `typography.fontSize.lg` | `18` | `--ml-fs-lg: 18px` | +| `typography.fontWeight.semibold` | `600` | — | + +### Spacing Tokens (8pt Grid) + +| Token | Value | CSS | +| ----------- | ----- | -------------------- | +| `spacing.1` | `4` | `--ml-space-1: 4px` | +| `spacing.4` | `16` | `--ml-space-4: 16px` | +| `spacing.8` | `32` | `--ml-space-8: 32px` | + +### Elevation Tokens + +| Token | CSS Output | +| -------------- | ------------------------------------------------- | +| `elevation.sm` | `--ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12)` | +| `elevation.md` | `--ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18)` | + +--- + +## Platform Usage + +### Web (CSS) + +```css +/* Import the CSS file */ +@import '@bytelyst/design-tokens/generated/tokens.css'; + +/* Use tokens */ +.my-component { + background-color: var(--ml-bg-canvas); + color: var(--ml-text-primary); + padding: var(--ml-space-4); + border-radius: var(--ml-radius-md); + box-shadow: var(--ml-elevation-sm); + font-family: var(--ml-font-display); + font-size: var(--ml-fs-lg); +} +``` + +#### With Tailwind CSS + +```html + +
+ Content +
+``` + +### Web (TypeScript) + +```typescript +import { tokens } from '@bytelyst/design-tokens/generated/tokens'; + +// Access any token programmatically +const primaryColor = tokens.color.semantic.dark.accentPrimary; +const fontSize = tokens.typography.fontSize.lg; +``` + +### iOS (SwiftUI) + +```swift +import SwiftUI + +// Copy MindLystTheme.swift to your project +// Rename to YourProductTheme.swift + +struct MyView: View { + var body: some View { + Text("Hello") + .foregroundColor(ProductColors.darkTextPrimary) + .background(ProductColors.darkBgCanvas) + .font(.system(size: ProductSpacing.x4)) + } +} +``` + +### Android/KMP (Kotlin + Compose) + +```kotlin +import com.mindlyst.shared.theme.MindLystTokens + +@Composable +fun MyComponent() { + Box( + modifier = Modifier + .background(Color(MindLystTokens.Dark.BG_CANVAS)) + .padding(MindLystTokens.Spacing.X4.dp) + ) { + Text( + text = "Hello", + color = Color(MindLystTokens.Dark.TEXT_PRIMARY), + fontSize = MindLystTokens.Typography.SIZE_LG.sp + ) + } +} +``` + +### React Native (Expo) + +```typescript +import { tokens } from '@bytelyst/design-tokens/generated/react-native/tokens'; +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + container: { + backgroundColor: tokens.colors.bgCanvas, + padding: tokens.spacing['4'], + borderRadius: tokens.radius.md, + }, + text: { + color: tokens.colors.textPrimary, + fontSize: tokens.typography.fontSize.lg, + }, +}); +``` + +--- + +## Product-Specific Tokens + +Each product has dedicated color tokens: + +```json +{ + "color": { + "nomgap": { + "stageFed": "#FF9F43", + "stageKetosis": "#5A8CFF", + "autophagyMeter": "#5AE68C" + }, + "chronomind": { + "urgencyCritical": "#FF6E6E", + "focusMode": "#7C6BFF" + }, + "peakpulse": { + "activityHike": "#34D399", + "speedZoneFast": "#FFD166" + } + } +} +``` + +--- + +## Validation & Compliance + +### Check for Hardcoded Colors + +```bash +# From your product repo +node ../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ +``` + +This scans for `#RRGGBB`, `rgb()`, `rgba()`, and `hsl()` patterns. + +### Get Token Coverage Report + +```bash +# From your product repo +node ../learning_ai_common_plat/packages/design-tokens/scripts/token-coverage.cjs src/ +``` + +Reports: + +- Percentage of files using tokens +- Number of hardcoded colors found +- Token adoption rate + +--- + +## CI Integration + +### GitHub Actions Example + +```yaml +- name: Check for hardcoded colors + run: | + node ../../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ + if [ $? -ne 0 ]; then + echo "❌ Hardcoded colors found. Use design tokens instead." + exit 1 + fi +``` + +### Pre-commit Hook + +```bash +# .husky/pre-commit or .git/hooks/pre-commit +node packages/design-tokens/scripts/validate-tokens.cjs src/ || true +``` + +--- + +## Token Categories (v1.1.0) + +| Category | Count | Description | +| -------------- | ----- | ------------------------------------------------ | +| **Color** | 80+ | Palette, semantic (dark/light), product-specific | +| **Typography** | 20+ | Font families, sizes, weights, line heights | +| **Spacing** | 13 | 8pt grid (0-64px) | +| **Radius** | 6 | Corner radii (xs to pill) | +| **Elevation** | 4 | Shadow depths | +| **Motion** | 7 | Durations, easings | +| **Z-Index** | 9 | Layer stacking | +| **Icon** | 6 | Icon sizes | +| **Grid** | 12 | 12-column system | +| **Opacity** | 11 | Alpha values | + +--- + +## Version History + +| Version | Date | Changes | +| ------- | ---------- | ------------------------------------------------------------------------------------------------- | +| 1.1.0 | 2026-03-03 | Added product tokens (PeakPulse, ChronoMind, NomGap, LysnrAI), z-index, icon sizes, grid, opacity | +| 1.0.0 | 2026-02-12 | Initial release with color, typography, spacing, radius, elevation, motion | + +--- + +## Contributing + +1. Edit `tokens/bytelyst.tokens.json` (canonical source) +2. Run `pnpm generate` to regenerate outputs +3. Copy generated files to consumer repos +4. Commit both source and generated files + +**Never edit generated files directly** — they will be overwritten. + +--- + +## See Also + +- [Design System Audit Report](../../docs/design-system/DESIGN_SYSTEM_AUDIT_2026-03-03.md) +- [AGENTS.md](../../AGENTS.md) — Coding agent instructions +- [ECOSYSTEM_ARCHITECTURE.md](../../docs/ECOSYSTEM_ARCHITECTURE.md) diff --git a/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz b/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz new file mode 100644 index 0000000..c526e95 Binary files /dev/null and b/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz differ diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift b/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift new file mode 100644 index 0000000..0209ca7 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift @@ -0,0 +1,88 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually +import SwiftUI + +// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens) +// These values mirror MindLystTokens.kt exactly. + +struct MindLystColors { + // Dark + static let darkBgCanvas = Color(hex: 0x06070A) + static let darkBgElevated = Color(hex: 0x0E1118) + static let darkSurfaceCard = Color(hex: 0x121725) + static let darkSurfaceMuted = Color(hex: 0x1A2335) + static let darkBorder = Color.white.opacity(0.12) + static let darkTextPrimary = Color(hex: 0xEFF4FF) + static let darkTextSecondary = Color(hex: 0xA5B1C7) + static let darkTextTertiary = Color(hex: 0x6C7C98) + static let darkAccentPrimary = Color(hex: 0x5A8CFF) + static let darkAccentSecondary = Color(hex: 0x2EE6D6) + static let darkSuccess = Color(hex: 0x34D399) + static let darkWarning = Color(hex: 0xF59E0B) + static let darkDanger = Color(hex: 0xFF6E6E) + + // Light + static let lightBgCanvas = Color(hex: 0xF6F8FC) + static let lightBgElevated = Color(hex: 0xEEF2FA) + static let lightSurfaceCard = Color.white + static let lightSurfaceMuted = Color(hex: 0xF3F5FA) + static let lightTextPrimary = Color(hex: 0x0E1320) + static let lightTextSecondary = Color(hex: 0x55637A) + static let lightTextTertiary = Color(hex: 0x6C7C98) + static let lightAccentPrimary = Color(hex: 0x5A8CFF) + static let lightAccentSecondary = Color(hex: 0x2EE6D6) + static let lightSuccess = Color(hex: 0x13956A) + static let lightWarning = Color(hex: 0xB87504) + static let lightDanger = Color(hex: 0xD24242) + + // Brain Gradients + static let brainWork = Gradient(colors: [Color(hex: 0x5A8CFF), Color(hex: 0x2EE6D6)]) + static let brainHome = Gradient(colors: [Color(hex: 0xFF6E6E), Color(hex: 0xFFD166)]) + static let brainMoney = Gradient(colors: [Color(hex: 0x34D399), Color(hex: 0x2EE6D6)]) + static let brainHealth = Gradient(colors: [Color(hex: 0x2EE6D6), Color(hex: 0x9FE870)]) + static let brainGlobal = Gradient(colors: [Color(hex: 0x7D8FB4), Color(hex: 0xA5B1C7)]) +} + +struct MindLystSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +struct MindLystRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +struct MindLystMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt b/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt new file mode 100644 index 0000000..20c4faf --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt @@ -0,0 +1,137 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually +package com.mindlyst.shared.theme + +/** + * Cross-platform design tokens from bytelyst.tokens.json. + * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI). + */ +object MindLystTokens { + + // ── Color Palette ──────────────────────────────────────────────── + object Palette { + const val NEUTRAL_0 = 0xFFFFFFFF + const val NEUTRAL_50 = 0xFFF6F8FC + const val NEUTRAL_100 = 0xFFEEF2FA + const val NEUTRAL_200 = 0xFFDCE4F2 + const val NEUTRAL_300 = 0xFFBFCBDE + const val NEUTRAL_400 = 0xFF92A1BA + const val NEUTRAL_500 = 0xFF6C7C98 + const val NEUTRAL_600 = 0xFF55637A + const val NEUTRAL_700 = 0xFF3B455A + const val NEUTRAL_800 = 0xFF1A2335 + const val NEUTRAL_900 = 0xFF0E1320 + const val NEUTRAL_950 = 0xFF06070A + + const val BLUE = 0xFF5A8CFF + const val CYAN = 0xFF2EE6D6 + const val CORAL = 0xFFFF6E6E + const val GOLD = 0xFFFFD166 + const val MINT = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val MICROSOFTRED = 0xFFF25022 + const val MICROSOFTGREEN = 0xFF7FBA00 + const val MICROSOFTBLUE = 0xFF00A4EF + const val MICROSOFTYELLOW = 0xFFFFB900 + const val GOOGLEBLUE = 0xFF4285F4 + const val GOOGLEGREEN = 0xFF34A853 + const val GOOGLEYELLOW = 0xFFFBBC05 + const val GOOGLERED = 0xFFEA4335 + } + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Brain Identity Gradients ───────────────────────────────────── + data class BrainGradient(val from: Long, val to: Long) + + val BRAIN_WORK = BrainGradient(from = 0xFF5A8CFF, to = 0xFF2EE6D6) + val BRAIN_HOME = BrainGradient(from = 0xFFFF6E6E, to = 0xFFFFD166) + val BRAIN_MONEY = BrainGradient(from = 0xFF34D399, to = 0xFF2EE6D6) + val BRAIN_HEALTH = BrainGradient(from = 0xFF2EE6D6, to = 0xFF9FE870) + val BRAIN_GLOBAL = BrainGradient(from = 0xFF7D8FB4, to = 0xFFA5B1C7) + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + + const val SIZE_XS = 12 + const val SIZE_SM = 14 + const val SIZE_MD = 16 + const val SIZE_LG = 18 + const val SIZE_XL = 22 + const val SIZE_2XL = 28 + const val SIZE_3XL = 36 + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } + + // ── Layout ─────────────────────────────────────────────────────── + object Layout { + const val TOUCH_TARGET_MIN = 44 + const val MOBILE_GUTTER = 16 + const val MAX_WIDTH = 1280 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/actiontrail.css b/vendor/bytelyst/design-tokens/generated/actiontrail.css new file mode 100644 index 0000000..521beb5 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/actiontrail.css @@ -0,0 +1,97 @@ +/* Auto-generated actiontrail tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --at-bg-canvas: #06070A; + --at-bg-elevated: #0E1118; + --at-surface-card: #121725; + --at-surface-muted: #1A2335; + --at-border-default: rgba(255,255,255,0.12); + --at-border-strong: rgba(255,255,255,0.22); + --at-text-primary: #EFF4FF; + --at-text-secondary: #A5B1C7; + --at-text-tertiary: #6C7C98; + --at-accent-primary: #5A8CFF; + --at-accent-secondary: #2EE6D6; + --at-success: #34D399; + --at-warning: #F59E0B; + --at-danger: #FF6E6E; + --at-focus-ring: rgba(90,140,255,0.45); + --at-overlay-scrim: rgba(5,8,18,0.72); + + /* actiontrail product colors */ + --at-bg: #07111F; + --at-surface: #0F1B2D; + --at-surface-elevated: #152338; + --at-border: #24344D; + --at-text: #EFF4FF; + --at-text-muted: #A8B4C8; + --at-primary: #5A8CFF; + --at-accent: #5AE68C; + --at-warning: #F59E0B; + --at-danger: #FF6E6E; + --at-risk-low: #5AE68C; + --at-risk-medium: #F59E0B; + --at-risk-high: #FF8C42; + --at-risk-critical: #FF6E6E; + --at-status-pending: #F59E0B; + --at-status-applied: #5AE68C; + --at-status-rejected: #FF6E6E; + --at-status-reverted: #A66BFF; + + --at-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --at-font-body: "DM Sans", "SF Pro Text", sans-serif; + --at-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --at-fs-xs: 12px; + --at-fs-sm: 14px; + --at-fs-md: 16px; + --at-fs-lg: 18px; + --at-fs-xl: 22px; + --at-fs-2xl: 28px; + --at-fs-3xl: 36px; + + --at-space-0: 0; + --at-space-1: 4px; + --at-space-2: 8px; + --at-space-3: 12px; + --at-space-4: 16px; + --at-space-5: 20px; + --at-space-6: 24px; + --at-space-7: 28px; + --at-space-8: 32px; + --at-space-10: 40px; + --at-space-12: 48px; + --at-space-16: 64px; + + --at-radius-xs: 8px; + --at-radius-sm: 12px; + --at-radius-md: 16px; + --at-radius-lg: 20px; + --at-radius-xl: 24px; + --at-radius-pill: 999px; + + --at-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --at-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --at-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --at-motion-fast: 140ms; + --at-motion-base: 220ms; + --at-motion-slow: 320ms; + --at-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --at-bg-canvas: #F6F8FC; + --at-bg-elevated: #EEF2FA; + --at-surface-card: #FFFFFF; + --at-surface-muted: #F3F5FA; + --at-border-default: rgba(14,19,32,0.12); + --at-border-strong: rgba(14,19,32,0.24); + --at-text-primary: #0E1320; + --at-text-secondary: #55637A; + --at-success: #13956A; + --at-warning: #B87504; + --at-danger: #D24242; + --at-focus-ring: rgba(90,140,255,0.35); + --at-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/chronomind.css b/vendor/bytelyst/design-tokens/generated/chronomind.css new file mode 100644 index 0000000..8e9d642 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/chronomind.css @@ -0,0 +1,89 @@ +/* Auto-generated chronomind tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --cm-bg-canvas: #06070A; + --cm-bg-elevated: #0E1118; + --cm-surface-card: #121725; + --cm-surface-muted: #1A2335; + --cm-border-default: rgba(255,255,255,0.12); + --cm-border-strong: rgba(255,255,255,0.22); + --cm-text-primary: #EFF4FF; + --cm-text-secondary: #A5B1C7; + --cm-text-tertiary: #6C7C98; + --cm-accent-primary: #5A8CFF; + --cm-accent-secondary: #2EE6D6; + --cm-success: #34D399; + --cm-warning: #F59E0B; + --cm-danger: #FF6E6E; + --cm-focus-ring: rgba(90,140,255,0.45); + --cm-overlay-scrim: rgba(5,8,18,0.72); + + /* chronomind product colors */ + --cm-urgency-critical: #FF6E6E; + --cm-urgency-important: #FFD166; + --cm-urgency-standard: #5A8CFF; + --cm-urgency-gentle: #34D399; + --cm-urgency-passive: #A5B1C7; + --cm-focus-mode: #7C6BFF; + --cm-pomodoro-work: #34D399; + --cm-pomodoro-break: #5A8CFF; + --cm-cascade-warning: #FF9F43; + --cm-timer-complete: #34D399; + + --cm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --cm-font-body: "DM Sans", "SF Pro Text", sans-serif; + --cm-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --cm-fs-xs: 12px; + --cm-fs-sm: 14px; + --cm-fs-md: 16px; + --cm-fs-lg: 18px; + --cm-fs-xl: 22px; + --cm-fs-2xl: 28px; + --cm-fs-3xl: 36px; + + --cm-space-0: 0; + --cm-space-1: 4px; + --cm-space-2: 8px; + --cm-space-3: 12px; + --cm-space-4: 16px; + --cm-space-5: 20px; + --cm-space-6: 24px; + --cm-space-7: 28px; + --cm-space-8: 32px; + --cm-space-10: 40px; + --cm-space-12: 48px; + --cm-space-16: 64px; + + --cm-radius-xs: 8px; + --cm-radius-sm: 12px; + --cm-radius-md: 16px; + --cm-radius-lg: 20px; + --cm-radius-xl: 24px; + --cm-radius-pill: 999px; + + --cm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --cm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --cm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --cm-motion-fast: 140ms; + --cm-motion-base: 220ms; + --cm-motion-slow: 320ms; + --cm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --cm-bg-canvas: #F6F8FC; + --cm-bg-elevated: #EEF2FA; + --cm-surface-card: #FFFFFF; + --cm-surface-muted: #F3F5FA; + --cm-border-default: rgba(14,19,32,0.12); + --cm-border-strong: rgba(14,19,32,0.24); + --cm-text-primary: #0E1320; + --cm-text-secondary: #55637A; + --cm-success: #13956A; + --cm-warning: #B87504; + --cm-danger: #D24242; + --cm-focus-ring: rgba(90,140,255,0.35); + --cm-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/flowmonk.css b/vendor/bytelyst/design-tokens/generated/flowmonk.css new file mode 100644 index 0000000..16e264f --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/flowmonk.css @@ -0,0 +1,99 @@ +/* Auto-generated flowmonk tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --fm-bg-canvas: #06070A; + --fm-bg-elevated: #0E1118; + --fm-surface-card: #121725; + --fm-surface-muted: #1A2335; + --fm-border-default: rgba(255,255,255,0.12); + --fm-border-strong: rgba(255,255,255,0.22); + --fm-text-primary: #EFF4FF; + --fm-text-secondary: #A5B1C7; + --fm-text-tertiary: #6C7C98; + --fm-accent-primary: #5A8CFF; + --fm-accent-secondary: #2EE6D6; + --fm-success: #34D399; + --fm-warning: #F59E0B; + --fm-danger: #FF6E6E; + --fm-focus-ring: rgba(90,140,255,0.45); + --fm-overlay-scrim: rgba(5,8,18,0.72); + + /* flowmonk product colors */ + --fm-bg: #07111F; + --fm-surface: #0F1B2D; + --fm-surface-elevated: #152338; + --fm-border: #24344D; + --fm-text: #EFF4FF; + --fm-text-muted: #A8B4C8; + --fm-primary: #5A8CFF; + --fm-accent: #5AE68C; + --fm-warning: #F59E0B; + --fm-zonework: #5A8CFF; + --fm-zone-personal: #5AE68C; + --fm-zone-health: #FF6B6B; + --fm-zone-admin: #FECA57; + --fm-zone-learning: #A66BFF; + --fm-urgent-badge: #FF6E6E; + --fm-schedule-entry: #5A8CFF; + --fm-overflow-warning: #F59E0B; + --fm-recommendation-info: #5A8CFF; + --fm-recommendation-warning: #F59E0B; + --fm-recommendation-critical: #FF6E6E; + + --fm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --fm-font-body: "DM Sans", "SF Pro Text", sans-serif; + --fm-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --fm-fs-xs: 12px; + --fm-fs-sm: 14px; + --fm-fs-md: 16px; + --fm-fs-lg: 18px; + --fm-fs-xl: 22px; + --fm-fs-2xl: 28px; + --fm-fs-3xl: 36px; + + --fm-space-0: 0; + --fm-space-1: 4px; + --fm-space-2: 8px; + --fm-space-3: 12px; + --fm-space-4: 16px; + --fm-space-5: 20px; + --fm-space-6: 24px; + --fm-space-7: 28px; + --fm-space-8: 32px; + --fm-space-10: 40px; + --fm-space-12: 48px; + --fm-space-16: 64px; + + --fm-radius-xs: 8px; + --fm-radius-sm: 12px; + --fm-radius-md: 16px; + --fm-radius-lg: 20px; + --fm-radius-xl: 24px; + --fm-radius-pill: 999px; + + --fm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --fm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --fm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --fm-motion-fast: 140ms; + --fm-motion-base: 220ms; + --fm-motion-slow: 320ms; + --fm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --fm-bg-canvas: #F6F8FC; + --fm-bg-elevated: #EEF2FA; + --fm-surface-card: #FFFFFF; + --fm-surface-muted: #F3F5FA; + --fm-border-default: rgba(14,19,32,0.12); + --fm-border-strong: rgba(14,19,32,0.24); + --fm-text-primary: #0E1320; + --fm-text-secondary: #55637A; + --fm-success: #13956A; + --fm-warning: #B87504; + --fm-danger: #D24242; + --fm-focus-ring: rgba(90,140,255,0.35); + --fm-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/jarvisjr.css b/vendor/bytelyst/design-tokens/generated/jarvisjr.css new file mode 100644 index 0000000..7fa5832 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/jarvisjr.css @@ -0,0 +1,88 @@ +/* Auto-generated jarvisjr tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --jj-bg-canvas: #06070A; + --jj-bg-elevated: #0E1118; + --jj-surface-card: #121725; + --jj-surface-muted: #1A2335; + --jj-border-default: rgba(255,255,255,0.12); + --jj-border-strong: rgba(255,255,255,0.22); + --jj-text-primary: #EFF4FF; + --jj-text-secondary: #A5B1C7; + --jj-text-tertiary: #6C7C98; + --jj-accent-primary: #5A8CFF; + --jj-accent-secondary: #2EE6D6; + --jj-success: #34D399; + --jj-warning: #F59E0B; + --jj-danger: #FF6E6E; + --jj-focus-ring: rgba(90,140,255,0.45); + --jj-overlay-scrim: rgba(5,8,18,0.72); + + /* jarvisjr product colors */ + --jj-accent-primary: #7C6BFF; + --jj-accent-secondary: #5AE6C8; + --jj-accent-voice: #FF6B8A; + --jj-agent-coach: #5A8CFF; + --jj-agent-lingua: #FFB74D; + --jj-agent-spark: #E040FB; + --jj-agent-mentor: #34D399; + --jj-agent-mirror: #2EE6D6; + --jj-agent-orator: #FF9F43; + + --jj-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --jj-font-body: "DM Sans", "SF Pro Text", sans-serif; + --jj-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --jj-fs-xs: 12px; + --jj-fs-sm: 14px; + --jj-fs-md: 16px; + --jj-fs-lg: 18px; + --jj-fs-xl: 22px; + --jj-fs-2xl: 28px; + --jj-fs-3xl: 36px; + + --jj-space-0: 0; + --jj-space-1: 4px; + --jj-space-2: 8px; + --jj-space-3: 12px; + --jj-space-4: 16px; + --jj-space-5: 20px; + --jj-space-6: 24px; + --jj-space-7: 28px; + --jj-space-8: 32px; + --jj-space-10: 40px; + --jj-space-12: 48px; + --jj-space-16: 64px; + + --jj-radius-xs: 8px; + --jj-radius-sm: 12px; + --jj-radius-md: 16px; + --jj-radius-lg: 20px; + --jj-radius-xl: 24px; + --jj-radius-pill: 999px; + + --jj-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --jj-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --jj-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --jj-motion-fast: 140ms; + --jj-motion-base: 220ms; + --jj-motion-slow: 320ms; + --jj-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --jj-bg-canvas: #F6F8FC; + --jj-bg-elevated: #EEF2FA; + --jj-surface-card: #FFFFFF; + --jj-surface-muted: #F3F5FA; + --jj-border-default: rgba(14,19,32,0.12); + --jj-border-strong: rgba(14,19,32,0.24); + --jj-text-primary: #0E1320; + --jj-text-secondary: #55637A; + --jj-success: #13956A; + --jj-warning: #B87504; + --jj-danger: #D24242; + --jj-focus-ring: rgba(90,140,255,0.35); + --jj-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/localllmlab.css b/vendor/bytelyst/design-tokens/generated/localllmlab.css new file mode 100644 index 0000000..2a62100 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/localllmlab.css @@ -0,0 +1,94 @@ +/* Auto-generated localllmlab tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --llm-bg-canvas: #06070A; + --llm-bg-elevated: #0E1118; + --llm-surface-card: #121725; + --llm-surface-muted: #1A2335; + --llm-border-default: rgba(255,255,255,0.12); + --llm-border-strong: rgba(255,255,255,0.22); + --llm-text-primary: #EFF4FF; + --llm-text-secondary: #A5B1C7; + --llm-text-tertiary: #6C7C98; + --llm-accent-primary: #5A8CFF; + --llm-accent-secondary: #2EE6D6; + --llm-success: #34D399; + --llm-warning: #F59E0B; + --llm-danger: #FF6E6E; + --llm-focus-ring: rgba(90,140,255,0.45); + --llm-overlay-scrim: rgba(5,8,18,0.72); + + /* localllmlab product colors */ + --llm-bg-canvas: #06070A; + --llm-bg-elevated: #0E1118; + --llm-surface-card: #121725; + --llm-surface-muted: #1A2335; + --llm-border-subtle: #1E293B; + --llm-border-default: #2A3654; + --llm-text-primary: #EFF4FF; + --llm-text-secondary: #A5B1C7; + --llm-text-tertiary: #6C7C98; + --llm-accent-primary: #5A8CFF; + --llm-accent-secondary: #2EE6D6; + --llm-success: #34D399; + --llm-warning: #F59E0B; + --llm-danger: #FF6E6E; + --llm-purple: #A78BFA; + + --llm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --llm-font-body: "DM Sans", "SF Pro Text", sans-serif; + --llm-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --llm-fs-xs: 12px; + --llm-fs-sm: 14px; + --llm-fs-md: 16px; + --llm-fs-lg: 18px; + --llm-fs-xl: 22px; + --llm-fs-2xl: 28px; + --llm-fs-3xl: 36px; + + --llm-space-0: 0; + --llm-space-1: 4px; + --llm-space-2: 8px; + --llm-space-3: 12px; + --llm-space-4: 16px; + --llm-space-5: 20px; + --llm-space-6: 24px; + --llm-space-7: 28px; + --llm-space-8: 32px; + --llm-space-10: 40px; + --llm-space-12: 48px; + --llm-space-16: 64px; + + --llm-radius-xs: 8px; + --llm-radius-sm: 12px; + --llm-radius-md: 16px; + --llm-radius-lg: 20px; + --llm-radius-xl: 24px; + --llm-radius-pill: 999px; + + --llm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --llm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --llm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --llm-motion-fast: 140ms; + --llm-motion-base: 220ms; + --llm-motion-slow: 320ms; + --llm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --llm-bg-canvas: #F6F8FC; + --llm-bg-elevated: #EEF2FA; + --llm-surface-card: #FFFFFF; + --llm-surface-muted: #F3F5FA; + --llm-border-default: rgba(14,19,32,0.12); + --llm-border-strong: rgba(14,19,32,0.24); + --llm-text-primary: #0E1320; + --llm-text-secondary: #55637A; + --llm-success: #13956A; + --llm-warning: #B87504; + --llm-danger: #D24242; + --llm-focus-ring: rgba(90,140,255,0.35); + --llm-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/localmemgpt.css b/vendor/bytelyst/design-tokens/generated/localmemgpt.css new file mode 100644 index 0000000..05beae5 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/localmemgpt.css @@ -0,0 +1,93 @@ +/* Auto-generated localmemgpt tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --lmg-bg-canvas: #06070A; + --lmg-bg-elevated: #0E1118; + --lmg-surface-card: #121725; + --lmg-surface-muted: #1A2335; + --lmg-border-default: rgba(255,255,255,0.12); + --lmg-border-strong: rgba(255,255,255,0.22); + --lmg-text-primary: #EFF4FF; + --lmg-text-secondary: #A5B1C7; + --lmg-text-tertiary: #6C7C98; + --lmg-accent-primary: #5A8CFF; + --lmg-accent-secondary: #2EE6D6; + --lmg-success: #34D399; + --lmg-warning: #F59E0B; + --lmg-danger: #FF6E6E; + --lmg-focus-ring: rgba(90,140,255,0.45); + --lmg-overlay-scrim: rgba(5,8,18,0.72); + + /* localmemgpt product colors */ + --lmg-bg-primary: #0A0A0A; + --lmg-bg-secondary: #141414; + --lmg-bg-tertiary: #1E1E1E; + --lmg-bg-hover: #252525; + --lmg-bg-input: #1A1A1A; + --lmg-border: #2A2A2A; + --lmg-text-primary: #F0F0F0; + --lmg-text-secondary: #999999; + --lmg-text-muted: #666666; + --lmg-accent: #6366F1; + --lmg-accent-hover: #818CF8; + --lmg-success: #22C55E; + --lmg-warning: #F59E0B; + --lmg-error: #EF4444; + + --lmg-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --lmg-font-body: "DM Sans", "SF Pro Text", sans-serif; + --lmg-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --lmg-fs-xs: 12px; + --lmg-fs-sm: 14px; + --lmg-fs-md: 16px; + --lmg-fs-lg: 18px; + --lmg-fs-xl: 22px; + --lmg-fs-2xl: 28px; + --lmg-fs-3xl: 36px; + + --lmg-space-0: 0; + --lmg-space-1: 4px; + --lmg-space-2: 8px; + --lmg-space-3: 12px; + --lmg-space-4: 16px; + --lmg-space-5: 20px; + --lmg-space-6: 24px; + --lmg-space-7: 28px; + --lmg-space-8: 32px; + --lmg-space-10: 40px; + --lmg-space-12: 48px; + --lmg-space-16: 64px; + + --lmg-radius-xs: 8px; + --lmg-radius-sm: 12px; + --lmg-radius-md: 16px; + --lmg-radius-lg: 20px; + --lmg-radius-xl: 24px; + --lmg-radius-pill: 999px; + + --lmg-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --lmg-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --lmg-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --lmg-motion-fast: 140ms; + --lmg-motion-base: 220ms; + --lmg-motion-slow: 320ms; + --lmg-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --lmg-bg-canvas: #F6F8FC; + --lmg-bg-elevated: #EEF2FA; + --lmg-surface-card: #FFFFFF; + --lmg-surface-muted: #F3F5FA; + --lmg-border-default: rgba(14,19,32,0.12); + --lmg-border-strong: rgba(14,19,32,0.24); + --lmg-text-primary: #0E1320; + --lmg-text-secondary: #55637A; + --lmg-success: #13956A; + --lmg-warning: #B87504; + --lmg-danger: #D24242; + --lmg-focus-ring: rgba(90,140,255,0.35); + --lmg-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/lysnrai.css b/vendor/bytelyst/design-tokens/generated/lysnrai.css new file mode 100644 index 0000000..383be4a --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/lysnrai.css @@ -0,0 +1,86 @@ +/* Auto-generated lysnrai tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --lys-bg-canvas: #06070A; + --lys-bg-elevated: #0E1118; + --lys-surface-card: #121725; + --lys-surface-muted: #1A2335; + --lys-border-default: rgba(255,255,255,0.12); + --lys-border-strong: rgba(255,255,255,0.22); + --lys-text-primary: #EFF4FF; + --lys-text-secondary: #A5B1C7; + --lys-text-tertiary: #6C7C98; + --lys-accent-primary: #5A8CFF; + --lys-accent-secondary: #2EE6D6; + --lys-success: #34D399; + --lys-warning: #F59E0B; + --lys-danger: #FF6E6E; + --lys-focus-ring: rgba(90,140,255,0.45); + --lys-overlay-scrim: rgba(5,8,18,0.72); + + /* lysnrai product colors */ + --lys-recording-active: #FF6E6E; + --lys-recording-paused: #FFD166; + --lys-processing: #5A8CFF; + --lys-transcribed: #34D399; + --lys-dictation-mode: #7C6BFF; + --lys-command-mode: #2EE6D6; + --lys-hotkey-active: #FF6B8A; + + --lys-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --lys-font-body: "DM Sans", "SF Pro Text", sans-serif; + --lys-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --lys-fs-xs: 12px; + --lys-fs-sm: 14px; + --lys-fs-md: 16px; + --lys-fs-lg: 18px; + --lys-fs-xl: 22px; + --lys-fs-2xl: 28px; + --lys-fs-3xl: 36px; + + --lys-space-0: 0; + --lys-space-1: 4px; + --lys-space-2: 8px; + --lys-space-3: 12px; + --lys-space-4: 16px; + --lys-space-5: 20px; + --lys-space-6: 24px; + --lys-space-7: 28px; + --lys-space-8: 32px; + --lys-space-10: 40px; + --lys-space-12: 48px; + --lys-space-16: 64px; + + --lys-radius-xs: 8px; + --lys-radius-sm: 12px; + --lys-radius-md: 16px; + --lys-radius-lg: 20px; + --lys-radius-xl: 24px; + --lys-radius-pill: 999px; + + --lys-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --lys-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --lys-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --lys-motion-fast: 140ms; + --lys-motion-base: 220ms; + --lys-motion-slow: 320ms; + --lys-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --lys-bg-canvas: #F6F8FC; + --lys-bg-elevated: #EEF2FA; + --lys-surface-card: #FFFFFF; + --lys-surface-muted: #F3F5FA; + --lys-border-default: rgba(14,19,32,0.12); + --lys-border-strong: rgba(14,19,32,0.24); + --lys-text-primary: #0E1320; + --lys-text-secondary: #55637A; + --lys-success: #13956A; + --lys-warning: #B87504; + --lys-danger: #D24242; + --lys-focus-ring: rgba(90,140,255,0.35); + --lys-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/mindlyst.css b/vendor/bytelyst/design-tokens/generated/mindlyst.css new file mode 100644 index 0000000..327e690 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/mindlyst.css @@ -0,0 +1,89 @@ +/* Auto-generated mindlyst tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --ml-bg-canvas: #06070A; + --ml-bg-elevated: #0E1118; + --ml-surface-card: #121725; + --ml-surface-muted: #1A2335; + --ml-border-default: rgba(255,255,255,0.12); + --ml-border-strong: rgba(255,255,255,0.22); + --ml-text-primary: #EFF4FF; + --ml-text-secondary: #A5B1C7; + --ml-text-tertiary: #6C7C98; + --ml-accent-primary: #5A8CFF; + --ml-accent-secondary: #2EE6D6; + --ml-success: #34D399; + --ml-warning: #F59E0B; + --ml-danger: #FF6E6E; + --ml-focus-ring: rgba(90,140,255,0.45); + --ml-overlay-scrim: rgba(5,8,18,0.72); + + /* mindlyst product colors */ + --ml-work-from: #5A8CFF; + --ml-work-to: #2EE6D6; + --ml-home-from: #FF6E6E; + --ml-home-to: #FFD166; + --ml-money-from: #34D399; + --ml-money-to: #2EE6D6; + --ml-health-from: #2EE6D6; + --ml-health-to: #9FE870; + --ml-global-from: #7D8FB4; + --ml-global-to: #A5B1C7; + + --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; + --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --ml-fs-xs: 12px; + --ml-fs-sm: 14px; + --ml-fs-md: 16px; + --ml-fs-lg: 18px; + --ml-fs-xl: 22px; + --ml-fs-2xl: 28px; + --ml-fs-3xl: 36px; + + --ml-space-0: 0; + --ml-space-1: 4px; + --ml-space-2: 8px; + --ml-space-3: 12px; + --ml-space-4: 16px; + --ml-space-5: 20px; + --ml-space-6: 24px; + --ml-space-7: 28px; + --ml-space-8: 32px; + --ml-space-10: 40px; + --ml-space-12: 48px; + --ml-space-16: 64px; + + --ml-radius-xs: 8px; + --ml-radius-sm: 12px; + --ml-radius-md: 16px; + --ml-radius-lg: 20px; + --ml-radius-xl: 24px; + --ml-radius-pill: 999px; + + --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --ml-motion-fast: 140ms; + --ml-motion-base: 220ms; + --ml-motion-slow: 320ms; + --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --ml-bg-canvas: #F6F8FC; + --ml-bg-elevated: #EEF2FA; + --ml-surface-card: #FFFFFF; + --ml-surface-muted: #F3F5FA; + --ml-border-default: rgba(14,19,32,0.12); + --ml-border-strong: rgba(14,19,32,0.24); + --ml-text-primary: #0E1320; + --ml-text-secondary: #55637A; + --ml-success: #13956A; + --ml-warning: #B87504; + --ml-danger: #D24242; + --ml-focus-ring: rgba(90,140,255,0.35); + --ml-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift new file mode 100644 index 0000000..2bb33f9 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift @@ -0,0 +1,103 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: actiontrail +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum ActionTrailColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Actiontrail Product Colors + static let bg = Color(hex: 0x07111F) + static let surface = Color(hex: 0x0F1B2D) + static let surfaceElevated = Color(hex: 0x152338) + static let border = Color(hex: 0x24344D) + static let text = Color(hex: 0xEFF4FF) + static let textMuted = Color(hex: 0xA8B4C8) + static let primary = Color(hex: 0x5A8CFF) + static let accent = Color(hex: 0x5AE68C) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + static let riskLow = Color(hex: 0x5AE68C) + static let riskMedium = Color(hex: 0xF59E0B) + static let riskHigh = Color(hex: 0xFF8C42) + static let riskCritical = Color(hex: 0xFF6E6E) + static let statusPending = Color(hex: 0xF59E0B) + static let statusApplied = Color(hex: 0x5AE68C) + static let statusRejected = Color(hex: 0xFF6E6E) + static let statusReverted = Color(hex: 0xA66BFF) + +} + +enum ActionTrailColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum ActionTrailSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum ActionTrailRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum ActionTrailMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt new file mode 100644 index 0000000..eba86c0 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt @@ -0,0 +1,102 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: actiontrail +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.actiontrail.theme + +object ActionTrailTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Actiontrail Product Colors ─────────────────────────────── + object Product { + const val BG = 0xFF07111F + const val SURFACE = 0xFF0F1B2D + const val SURFACE_ELEVATED = 0xFF152338 + const val BORDER = 0xFF24344D + const val TEXT = 0xFFEFF4FF + const val TEXT_MUTED = 0xFFA8B4C8 + const val PRIMARY = 0xFF5A8CFF + const val ACCENT = 0xFF5AE68C + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + const val RISK_LOW = 0xFF5AE68C + const val RISK_MEDIUM = 0xFFF59E0B + const val RISK_HIGH = 0xFFFF8C42 + const val RISK_CRITICAL = 0xFFFF6E6E + const val STATUS_PENDING = 0xFFF59E0B + const val STATUS_APPLIED = 0xFF5AE68C + const val STATUS_REJECTED = 0xFFFF6E6E + const val STATUS_REVERTED = 0xFFA66BFF + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift new file mode 100644 index 0000000..8b4c085 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift @@ -0,0 +1,95 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: chronomind +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum CMColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Chronomind Product Colors + static let urgencyCritical = Color(hex: 0xFF6E6E) + static let urgencyImportant = Color(hex: 0xFFD166) + static let urgencyStandard = Color(hex: 0x5A8CFF) + static let urgencyGentle = Color(hex: 0x34D399) + static let urgencyPassive = Color(hex: 0xA5B1C7) + static let focusMode = Color(hex: 0x7C6BFF) + static let pomodoroWork = Color(hex: 0x34D399) + static let pomodoroBreak = Color(hex: 0x5A8CFF) + static let cascadeWarning = Color(hex: 0xFF9F43) + static let timerComplete = Color(hex: 0x34D399) + +} + +enum CMColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum CMSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum CMRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum CMMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt new file mode 100644 index 0000000..09aafe1 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt @@ -0,0 +1,94 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: chronomind +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.chronomind.app.theme + +object ChronoMindTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Chronomind Product Colors ─────────────────────────────── + object Product { + const val URGENCY_CRITICAL = 0xFFFF6E6E + const val URGENCY_IMPORTANT = 0xFFFFD166 + const val URGENCY_STANDARD = 0xFF5A8CFF + const val URGENCY_GENTLE = 0xFF34D399 + const val URGENCY_PASSIVE = 0xFFA5B1C7 + const val FOCUS_MODE = 0xFF7C6BFF + const val POMODORO_WORK = 0xFF34D399 + const val POMODORO_BREAK = 0xFF5A8CFF + const val CASCADE_WARNING = 0xFFFF9F43 + const val TIMER_COMPLETE = 0xFF34D399 + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift new file mode 100644 index 0000000..6701005 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift @@ -0,0 +1,105 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: flowmonk +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum FlowMonkColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Flowmonk Product Colors + static let bg = Color(hex: 0x07111F) + static let surface = Color(hex: 0x0F1B2D) + static let surfaceElevated = Color(hex: 0x152338) + static let border = Color(hex: 0x24344D) + static let text = Color(hex: 0xEFF4FF) + static let textMuted = Color(hex: 0xA8B4C8) + static let primary = Color(hex: 0x5A8CFF) + static let accent = Color(hex: 0x5AE68C) + static let warning = Color(hex: 0xF59E0B) + static let zonework = Color(hex: 0x5A8CFF) + static let zonePersonal = Color(hex: 0x5AE68C) + static let zoneHealth = Color(hex: 0xFF6B6B) + static let zoneAdmin = Color(hex: 0xFECA57) + static let zoneLearning = Color(hex: 0xA66BFF) + static let urgentBadge = Color(hex: 0xFF6E6E) + static let scheduleEntry = Color(hex: 0x5A8CFF) + static let overflowWarning = Color(hex: 0xF59E0B) + static let recommendationInfo = Color(hex: 0x5A8CFF) + static let recommendationWarning = Color(hex: 0xF59E0B) + static let recommendationCritical = Color(hex: 0xFF6E6E) + +} + +enum FlowMonkColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum FlowMonkSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum FlowMonkRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum FlowMonkMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt new file mode 100644 index 0000000..fdc64a4 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt @@ -0,0 +1,104 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: flowmonk +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.flowmonk.theme + +object FlowMonkTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Flowmonk Product Colors ─────────────────────────────── + object Product { + const val BG = 0xFF07111F + const val SURFACE = 0xFF0F1B2D + const val SURFACE_ELEVATED = 0xFF152338 + const val BORDER = 0xFF24344D + const val TEXT = 0xFFEFF4FF + const val TEXT_MUTED = 0xFFA8B4C8 + const val PRIMARY = 0xFF5A8CFF + const val ACCENT = 0xFF5AE68C + const val WARNING = 0xFFF59E0B + const val ZONEWORK = 0xFF5A8CFF + const val ZONE_PERSONAL = 0xFF5AE68C + const val ZONE_HEALTH = 0xFFFF6B6B + const val ZONE_ADMIN = 0xFFFECA57 + const val ZONE_LEARNING = 0xFFA66BFF + const val URGENT_BADGE = 0xFFFF6E6E + const val SCHEDULE_ENTRY = 0xFF5A8CFF + const val OVERFLOW_WARNING = 0xFFF59E0B + const val RECOMMENDATION_INFO = 0xFF5A8CFF + const val RECOMMENDATION_WARNING = 0xFFF59E0B + const val RECOMMENDATION_CRITICAL = 0xFFFF6E6E + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift new file mode 100644 index 0000000..0d88174 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift @@ -0,0 +1,94 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: jarvisjr +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum JarvisJrColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Jarvisjr Product Colors + static let accentPrimary = Color(hex: 0x7C6BFF) + static let accentSecondary = Color(hex: 0x5AE6C8) + static let accentVoice = Color(hex: 0xFF6B8A) + static let agentCoach = Color(hex: 0x5A8CFF) + static let agentLingua = Color(hex: 0xFFB74D) + static let agentSpark = Color(hex: 0xE040FB) + static let agentMentor = Color(hex: 0x34D399) + static let agentMirror = Color(hex: 0x2EE6D6) + static let agentOrator = Color(hex: 0xFF9F43) + +} + +enum JarvisJrColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum JarvisJrSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum JarvisJrRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum JarvisJrMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt new file mode 100644 index 0000000..6537682 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt @@ -0,0 +1,93 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: jarvisjr +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.jarvisjr.app.theme + +object JarvisJrTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Jarvisjr Product Colors ─────────────────────────────── + object Product { + const val ACCENT_PRIMARY = 0xFF7C6BFF + const val ACCENT_SECONDARY = 0xFF5AE6C8 + const val ACCENT_VOICE = 0xFFFF6B8A + const val AGENT_COACH = 0xFF5A8CFF + const val AGENT_LINGUA = 0xFFFFB74D + const val AGENT_SPARK = 0xFFE040FB + const val AGENT_MENTOR = 0xFF34D399 + const val AGENT_MIRROR = 0xFF2EE6D6 + const val AGENT_ORATOR = 0xFFFF9F43 + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift new file mode 100644 index 0000000..e75de29 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift @@ -0,0 +1,100 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: localllmlab +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum LocalLLMLabColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Localllmlab Product Colors + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let borderSubtle = Color(hex: 0x1E293B) + static let borderDefault = Color(hex: 0x2A3654) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + static let purple = Color(hex: 0xA78BFA) + +} + +enum LocalLLMLabColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum LocalLLMLabSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum LocalLLMLabRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum LocalLLMLabMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt new file mode 100644 index 0000000..bac6a9b --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt @@ -0,0 +1,99 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: localllmlab +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.localllmlab.theme + +object LocalLLMLabTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Localllmlab Product Colors ─────────────────────────────── + object Product { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val BORDER_SUBTLE = 0xFF1E293B + const val BORDER_DEFAULT = 0xFF2A3654 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + const val PURPLE = 0xFFA78BFA + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift new file mode 100644 index 0000000..6c3d11c --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift @@ -0,0 +1,99 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: localmemgpt +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum LocalMemGPTColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Localmemgpt Product Colors + static let bgPrimary = Color(hex: 0x0A0A0A) + static let bgSecondary = Color(hex: 0x141414) + static let bgTertiary = Color(hex: 0x1E1E1E) + static let bgHover = Color(hex: 0x252525) + static let bgInput = Color(hex: 0x1A1A1A) + static let border = Color(hex: 0x2A2A2A) + static let textPrimary = Color(hex: 0xF0F0F0) + static let textSecondary = Color(hex: 0x999999) + static let textMuted = Color(hex: 0x666666) + static let accent = Color(hex: 0x6366F1) + static let accentHover = Color(hex: 0x818CF8) + static let success = Color(hex: 0x22C55E) + static let warning = Color(hex: 0xF59E0B) + static let error = Color(hex: 0xEF4444) + +} + +enum LocalMemGPTColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum LocalMemGPTSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum LocalMemGPTRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum LocalMemGPTMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt new file mode 100644 index 0000000..51ae467 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt @@ -0,0 +1,98 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: localmemgpt +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.localmemgpt.theme + +object LocalMemGPTTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Localmemgpt Product Colors ─────────────────────────────── + object Product { + const val BG_PRIMARY = 0xFF0A0A0A + const val BG_SECONDARY = 0xFF141414 + const val BG_TERTIARY = 0xFF1E1E1E + const val BG_HOVER = 0xFF252525 + const val BG_INPUT = 0xFF1A1A1A + const val BORDER = 0xFF2A2A2A + const val TEXT_PRIMARY = 0xFFF0F0F0 + const val TEXT_SECONDARY = 0xFF999999 + const val TEXT_MUTED = 0xFF666666 + const val ACCENT = 0xFF6366F1 + const val ACCENT_HOVER = 0xFF818CF8 + const val SUCCESS = 0xFF22C55E + const val WARNING = 0xFFF59E0B + const val ERROR = 0xFFEF4444 + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift new file mode 100644 index 0000000..ebe950e --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift @@ -0,0 +1,92 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: lysnrai +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum LysnrAIColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Lysnrai Product Colors + static let recordingActive = Color(hex: 0xFF6E6E) + static let recordingPaused = Color(hex: 0xFFD166) + static let processing = Color(hex: 0x5A8CFF) + static let transcribed = Color(hex: 0x34D399) + static let dictationMode = Color(hex: 0x7C6BFF) + static let commandMode = Color(hex: 0x2EE6D6) + static let hotkeyActive = Color(hex: 0xFF6B8A) + +} + +enum LysnrAIColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum LysnrAISpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum LysnrAIRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum LysnrAIMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt new file mode 100644 index 0000000..1fffbf3 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt @@ -0,0 +1,91 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: lysnrai +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.saravana.lysnrai.theme + +object LysnrAITokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Lysnrai Product Colors ─────────────────────────────── + object Product { + const val RECORDING_ACTIVE = 0xFFFF6E6E + const val RECORDING_PAUSED = 0xFFFFD166 + const val PROCESSING = 0xFF5A8CFF + const val TRANSCRIBED = 0xFF34D399 + const val DICTATION_MODE = 0xFF7C6BFF + const val COMMAND_MODE = 0xFF2EE6D6 + const val HOTKEY_ACTIVE = 0xFFFF6B8A + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift new file mode 100644 index 0000000..c681d96 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift @@ -0,0 +1,95 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: nomgap +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum NomGapColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Nomgap Product Colors + static let stageFed = Color(hex: 0xFF9F43) + static let stageEarlyFast = Color(hex: 0xFECA57) + static let stageFasted = Color(hex: 0x48DBFB) + static let stageKetosis = Color(hex: 0x5A8CFF) + static let stageDeepAutophagy = Color(hex: 0xA66BFF) + static let stageExtended = Color(hex: 0xFFD700) + static let autophagyMeter = Color(hex: 0x5AE68C) + static let hydrationReminder = Color(hex: 0x48DBFB) + static let electrolyteAlert = Color(hex: 0xFF9F43) + static let safetyWarning = Color(hex: 0xFF6E6E) + +} + +enum NomGapColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum NomGapSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum NomGapRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum NomGapMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt new file mode 100644 index 0000000..d7c9964 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt @@ -0,0 +1,94 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: nomgap +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.nomgap.theme + +object NomGapTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Nomgap Product Colors ─────────────────────────────── + object Product { + const val STAGE_FED = 0xFFFF9F43 + const val STAGE_EARLY_FAST = 0xFFFECA57 + const val STAGE_FASTED = 0xFF48DBFB + const val STAGE_KETOSIS = 0xFF5A8CFF + const val STAGE_DEEP_AUTOPHAGY = 0xFFA66BFF + const val STAGE_EXTENDED = 0xFFFFD700 + const val AUTOPHAGY_METER = 0xFF5AE68C + const val HYDRATION_REMINDER = 0xFF48DBFB + const val ELECTROLYTE_ALERT = 0xFFFF9F43 + const val SAFETY_WARNING = 0xFFFF6E6E + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift new file mode 100644 index 0000000..eacca13 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift @@ -0,0 +1,100 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: notelett +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum NoteLettColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Notelett Product Colors + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + static let focusRing = Color(hex: 0x5A8CFF) + static let agentAction = Color(hex: 0xA66BFF) + static let draftNote = Color(hex: 0xFFD166) + static let linkedNote = Color(hex: 0x2EE6D6) + static let taskPending = Color(hex: 0xF59E0B) + static let taskComplete = Color(hex: 0x34D399) + +} + +enum NoteLettColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum NoteLettSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum NoteLettRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum NoteLettMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt new file mode 100644 index 0000000..700b3cc --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt @@ -0,0 +1,99 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: notelett +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.notelett.theme + +object NoteLettTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Notelett Product Colors ─────────────────────────────── + object Product { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + const val FOCUS_RING = 0xFF5A8CFF + const val AGENT_ACTION = 0xFFA66BFF + const val DRAFT_NOTE = 0xFFFFD166 + const val LINKED_NOTE = 0xFF2EE6D6 + const val TASK_PENDING = 0xFFF59E0B + const val TASK_COMPLETE = 0xFF34D399 + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift new file mode 100644 index 0000000..ffbec17 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift @@ -0,0 +1,95 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: peakpulse +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts + +import SwiftUI + +enum PeakPulseColors { + // MARK: - Semantic (Dark Theme) + static let bgCanvas = Color(hex: 0x06070A) + static let bgElevated = Color(hex: 0x0E1118) + static let surfaceCard = Color(hex: 0x121725) + static let surfaceMuted = Color(hex: 0x1A2335) + static let textPrimary = Color(hex: 0xEFF4FF) + static let textSecondary = Color(hex: 0xA5B1C7) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x34D399) + static let warning = Color(hex: 0xF59E0B) + static let danger = Color(hex: 0xFF6E6E) + + // MARK: - Peakpulse Product Colors + static let activityHike = Color(hex: 0x34D399) + static let activitySki = Color(hex: 0x5A8CFF) + static let speedZoneSlow = Color(hex: 0x34D399) + static let speedZoneFast = Color(hex: 0xFFD166) + static let speedZoneDanger = Color(hex: 0xFF6E6E) + static let elevationGain = Color(hex: 0x2EE6D6) + static let elevationLoss = Color(hex: 0xFF9F43) + static let personalBest = Color(hex: 0xFFD700) + static let streakActive = Color(hex: 0x34D399) + static let streakBroken = Color(hex: 0xFF6E6E) + +} + +enum PeakPulseColorsLight { + // MARK: - Semantic (Light Theme) + static let bgCanvas = Color(hex: 0xF6F8FC) + static let bgElevated = Color(hex: 0xEEF2FA) + static let surfaceCard = Color(hex: 0xFFFFFF) + static let surfaceMuted = Color(hex: 0xF3F5FA) + static let textPrimary = Color(hex: 0x0E1320) + static let textSecondary = Color(hex: 0x55637A) + static let textTertiary = Color(hex: 0x6C7C98) + static let accentPrimary = Color(hex: 0x5A8CFF) + static let accentSecondary = Color(hex: 0x2EE6D6) + static let success = Color(hex: 0x13956A) + static let warning = Color(hex: 0xB87504) + static let danger = Color(hex: 0xD24242) +} + +enum PeakPulseSpacing { + static let x0: CGFloat = 0 + static let x1: CGFloat = 4 + static let x2: CGFloat = 8 + static let x3: CGFloat = 12 + static let x4: CGFloat = 16 + static let x5: CGFloat = 20 + static let x6: CGFloat = 24 + static let x7: CGFloat = 28 + static let x8: CGFloat = 32 + static let x10: CGFloat = 40 + static let x12: CGFloat = 48 + static let x16: CGFloat = 64 +} + +enum PeakPulseRadius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 12 + static let md: CGFloat = 16 + static let lg: CGFloat = 20 + static let xl: CGFloat = 24 + static let pill: CGFloat = 999 +} + +enum PeakPulseMotion { + static let instant: Double = 0.07 + static let fast: Double = 0.14 + static let base: Double = 0.22 + static let slow: Double = 0.32 +} + +// MARK: - Color Hex Extension (import if not already defined) + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt new file mode 100644 index 0000000..2848bdd --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt @@ -0,0 +1,94 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually. +// Product: peakpulse +// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts +package com.peakpulse.theme + +object PeakPulseTokens { + + // ── Semantic Colors (Dark Theme) ───────────────────────────────── + object Dark { + const val BG_CANVAS = 0xFF06070A + const val BG_ELEVATED = 0xFF0E1118 + const val SURFACE_CARD = 0xFF121725 + const val SURFACE_MUTED = 0xFF1A2335 + const val TEXT_PRIMARY = 0xFFEFF4FF + const val TEXT_SECONDARY = 0xFFA5B1C7 + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF34D399 + const val WARNING = 0xFFF59E0B + const val DANGER = 0xFFFF6E6E + } + + // ── Semantic Colors (Light Theme) ──────────────────────────────── + object Light { + const val BG_CANVAS = 0xFFF6F8FC + const val BG_ELEVATED = 0xFFEEF2FA + const val SURFACE_CARD = 0xFFFFFFFF + const val SURFACE_MUTED = 0xFFF3F5FA + const val TEXT_PRIMARY = 0xFF0E1320 + const val TEXT_SECONDARY = 0xFF55637A + const val TEXT_TERTIARY = 0xFF6C7C98 + const val ACCENT_PRIMARY = 0xFF5A8CFF + const val ACCENT_SECONDARY = 0xFF2EE6D6 + const val SUCCESS = 0xFF13956A + const val WARNING = 0xFFB87504 + const val DANGER = 0xFFD24242 + } + + // ── Peakpulse Product Colors ─────────────────────────────── + object Product { + const val ACTIVITY_HIKE = 0xFF34D399 + const val ACTIVITY_SKI = 0xFF5A8CFF + const val SPEED_ZONE_SLOW = 0xFF34D399 + const val SPEED_ZONE_FAST = 0xFFFFD166 + const val SPEED_ZONE_DANGER = 0xFFFF6E6E + const val ELEVATION_GAIN = 0xFF2EE6D6 + const val ELEVATION_LOSS = 0xFFFF9F43 + const val PERSONAL_BEST = 0xFFFFD700 + const val STREAK_ACTIVE = 0xFF34D399 + const val STREAK_BROKEN = 0xFFFF6E6E + } + + // ── Spacing (8pt grid) ─────────────────────────────────────────── + object Spacing { + const val X0 = 0 + const val X1 = 4 + const val X2 = 8 + const val X3 = 12 + const val X4 = 16 + const val X5 = 20 + const val X6 = 24 + const val X7 = 28 + const val X8 = 32 + const val X10 = 40 + const val X12 = 48 + const val X16 = 64 + } + + // ── Radius ─────────────────────────────────────────────────────── + object Radius { + const val XS = 8 + const val SM = 12 + const val MD = 16 + const val LG = 20 + const val XL = 24 + const val PILL = 999 + } + + // ── Typography ─────────────────────────────────────────────────── + object Typography { + const val FONT_DISPLAY = "Space Grotesk" + const val FONT_BODY = "DM Sans" + const val FONT_MONO = "IBM Plex Mono" + } + + // ── Motion ─────────────────────────────────────────────────────── + object Motion { + const val INSTANT = 70 + const val FAST = 140 + const val BASE = 220 + const val SLOW = 320 + } +} diff --git a/vendor/bytelyst/design-tokens/generated/nomgap.css b/vendor/bytelyst/design-tokens/generated/nomgap.css new file mode 100644 index 0000000..18294b6 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/nomgap.css @@ -0,0 +1,89 @@ +/* Auto-generated nomgap tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --ng-bg-canvas: #06070A; + --ng-bg-elevated: #0E1118; + --ng-surface-card: #121725; + --ng-surface-muted: #1A2335; + --ng-border-default: rgba(255,255,255,0.12); + --ng-border-strong: rgba(255,255,255,0.22); + --ng-text-primary: #EFF4FF; + --ng-text-secondary: #A5B1C7; + --ng-text-tertiary: #6C7C98; + --ng-accent-primary: #5A8CFF; + --ng-accent-secondary: #2EE6D6; + --ng-success: #34D399; + --ng-warning: #F59E0B; + --ng-danger: #FF6E6E; + --ng-focus-ring: rgba(90,140,255,0.45); + --ng-overlay-scrim: rgba(5,8,18,0.72); + + /* nomgap product colors */ + --ng-stage-fed: #FF9F43; + --ng-stage-early-fast: #FECA57; + --ng-stage-fasted: #48DBFB; + --ng-stage-ketosis: #5A8CFF; + --ng-stage-deep-autophagy: #A66BFF; + --ng-stage-extended: #FFD700; + --ng-autophagy-meter: #5AE68C; + --ng-hydration-reminder: #48DBFB; + --ng-electrolyte-alert: #FF9F43; + --ng-safety-warning: #FF6E6E; + + --ng-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --ng-font-body: "DM Sans", "SF Pro Text", sans-serif; + --ng-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --ng-fs-xs: 12px; + --ng-fs-sm: 14px; + --ng-fs-md: 16px; + --ng-fs-lg: 18px; + --ng-fs-xl: 22px; + --ng-fs-2xl: 28px; + --ng-fs-3xl: 36px; + + --ng-space-0: 0; + --ng-space-1: 4px; + --ng-space-2: 8px; + --ng-space-3: 12px; + --ng-space-4: 16px; + --ng-space-5: 20px; + --ng-space-6: 24px; + --ng-space-7: 28px; + --ng-space-8: 32px; + --ng-space-10: 40px; + --ng-space-12: 48px; + --ng-space-16: 64px; + + --ng-radius-xs: 8px; + --ng-radius-sm: 12px; + --ng-radius-md: 16px; + --ng-radius-lg: 20px; + --ng-radius-xl: 24px; + --ng-radius-pill: 999px; + + --ng-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --ng-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --ng-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --ng-motion-fast: 140ms; + --ng-motion-base: 220ms; + --ng-motion-slow: 320ms; + --ng-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --ng-bg-canvas: #F6F8FC; + --ng-bg-elevated: #EEF2FA; + --ng-surface-card: #FFFFFF; + --ng-surface-muted: #F3F5FA; + --ng-border-default: rgba(14,19,32,0.12); + --ng-border-strong: rgba(14,19,32,0.24); + --ng-text-primary: #0E1320; + --ng-text-secondary: #55637A; + --ng-success: #13956A; + --ng-warning: #B87504; + --ng-danger: #D24242; + --ng-focus-ring: rgba(90,140,255,0.35); + --ng-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/notelett.css b/vendor/bytelyst/design-tokens/generated/notelett.css new file mode 100644 index 0000000..c81806f --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/notelett.css @@ -0,0 +1,94 @@ +/* Auto-generated notelett tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --nl-bg-canvas: #06070A; + --nl-bg-elevated: #0E1118; + --nl-surface-card: #121725; + --nl-surface-muted: #1A2335; + --nl-border-default: rgba(255,255,255,0.12); + --nl-border-strong: rgba(255,255,255,0.22); + --nl-text-primary: #EFF4FF; + --nl-text-secondary: #A5B1C7; + --nl-text-tertiary: #6C7C98; + --nl-accent-primary: #5A8CFF; + --nl-accent-secondary: #2EE6D6; + --nl-success: #34D399; + --nl-warning: #F59E0B; + --nl-danger: #FF6E6E; + --nl-focus-ring: rgba(90,140,255,0.45); + --nl-overlay-scrim: rgba(5,8,18,0.72); + + /* notelett product colors */ + --nl-bg-canvas: #06070A; + --nl-bg-elevated: #0E1118; + --nl-surface-card: #121725; + --nl-surface-muted: #1A2335; + --nl-accent-primary: #5A8CFF; + --nl-accent-secondary: #2EE6D6; + --nl-success: #34D399; + --nl-warning: #F59E0B; + --nl-danger: #FF6E6E; + --nl-focus-ring: #5A8CFF; + --nl-agent-action: #A66BFF; + --nl-draft-note: #FFD166; + --nl-linked-note: #2EE6D6; + --nl-task-pending: #F59E0B; + --nl-task-complete: #34D399; + + --nl-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --nl-font-body: "DM Sans", "SF Pro Text", sans-serif; + --nl-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --nl-fs-xs: 12px; + --nl-fs-sm: 14px; + --nl-fs-md: 16px; + --nl-fs-lg: 18px; + --nl-fs-xl: 22px; + --nl-fs-2xl: 28px; + --nl-fs-3xl: 36px; + + --nl-space-0: 0; + --nl-space-1: 4px; + --nl-space-2: 8px; + --nl-space-3: 12px; + --nl-space-4: 16px; + --nl-space-5: 20px; + --nl-space-6: 24px; + --nl-space-7: 28px; + --nl-space-8: 32px; + --nl-space-10: 40px; + --nl-space-12: 48px; + --nl-space-16: 64px; + + --nl-radius-xs: 8px; + --nl-radius-sm: 12px; + --nl-radius-md: 16px; + --nl-radius-lg: 20px; + --nl-radius-xl: 24px; + --nl-radius-pill: 999px; + + --nl-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --nl-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --nl-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --nl-motion-fast: 140ms; + --nl-motion-base: 220ms; + --nl-motion-slow: 320ms; + --nl-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --nl-bg-canvas: #F6F8FC; + --nl-bg-elevated: #EEF2FA; + --nl-surface-card: #FFFFFF; + --nl-surface-muted: #F3F5FA; + --nl-border-default: rgba(14,19,32,0.12); + --nl-border-strong: rgba(14,19,32,0.24); + --nl-text-primary: #0E1320; + --nl-text-secondary: #55637A; + --nl-success: #13956A; + --nl-warning: #B87504; + --nl-danger: #D24242; + --nl-focus-ring: rgba(90,140,255,0.35); + --nl-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/peakpulse.css b/vendor/bytelyst/design-tokens/generated/peakpulse.css new file mode 100644 index 0000000..24cf13a --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/peakpulse.css @@ -0,0 +1,89 @@ +/* Auto-generated peakpulse tokens from bytelyst.tokens.json — do not edit manually */ + +:root { + --pp-bg-canvas: #06070A; + --pp-bg-elevated: #0E1118; + --pp-surface-card: #121725; + --pp-surface-muted: #1A2335; + --pp-border-default: rgba(255,255,255,0.12); + --pp-border-strong: rgba(255,255,255,0.22); + --pp-text-primary: #EFF4FF; + --pp-text-secondary: #A5B1C7; + --pp-text-tertiary: #6C7C98; + --pp-accent-primary: #5A8CFF; + --pp-accent-secondary: #2EE6D6; + --pp-success: #34D399; + --pp-warning: #F59E0B; + --pp-danger: #FF6E6E; + --pp-focus-ring: rgba(90,140,255,0.45); + --pp-overlay-scrim: rgba(5,8,18,0.72); + + /* peakpulse product colors */ + --pp-activity-hike: #34D399; + --pp-activity-ski: #5A8CFF; + --pp-speed-zone-slow: #34D399; + --pp-speed-zone-fast: #FFD166; + --pp-speed-zone-danger: #FF6E6E; + --pp-elevation-gain: #2EE6D6; + --pp-elevation-loss: #FF9F43; + --pp-personal-best: #FFD700; + --pp-streak-active: #34D399; + --pp-streak-broken: #FF6E6E; + + --pp-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --pp-font-body: "DM Sans", "SF Pro Text", sans-serif; + --pp-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --pp-fs-xs: 12px; + --pp-fs-sm: 14px; + --pp-fs-md: 16px; + --pp-fs-lg: 18px; + --pp-fs-xl: 22px; + --pp-fs-2xl: 28px; + --pp-fs-3xl: 36px; + + --pp-space-0: 0; + --pp-space-1: 4px; + --pp-space-2: 8px; + --pp-space-3: 12px; + --pp-space-4: 16px; + --pp-space-5: 20px; + --pp-space-6: 24px; + --pp-space-7: 28px; + --pp-space-8: 32px; + --pp-space-10: 40px; + --pp-space-12: 48px; + --pp-space-16: 64px; + + --pp-radius-xs: 8px; + --pp-radius-sm: 12px; + --pp-radius-md: 16px; + --pp-radius-lg: 20px; + --pp-radius-xl: 24px; + --pp-radius-pill: 999px; + + --pp-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --pp-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --pp-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --pp-motion-fast: 140ms; + --pp-motion-base: 220ms; + --pp-motion-slow: 320ms; + --pp-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --pp-bg-canvas: #F6F8FC; + --pp-bg-elevated: #EEF2FA; + --pp-surface-card: #FFFFFF; + --pp-surface-muted: #F3F5FA; + --pp-border-default: rgba(14,19,32,0.12); + --pp-border-strong: rgba(14,19,32,0.24); + --pp-text-primary: #0E1320; + --pp-text-secondary: #55637A; + --pp-success: #13956A; + --pp-warning: #B87504; + --pp-danger: #D24242; + --pp-focus-ring: rgba(90,140,255,0.35); + --pp-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts b/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts new file mode 100644 index 0000000..ddb25ca --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts @@ -0,0 +1,139 @@ +/** + * React Native Design Tokens — Auto-generated from bytelyst.tokens.json + * Do not edit manually. Run: tsx scripts/generate-react-native.ts + */ + +export const tokens = { + // ── Semantic Colors (Dark Theme) ────────────────────────────────── + colors: { + bgCanvas: '#06070A', + bgElevated: '#0E1118', + surfaceCard: '#121725', + surfaceMuted: '#1A2335', + borderDefault: '#1FFFFFFF', + borderStrong: '#38FFFFFF', + textPrimary: '#EFF4FF', + textSecondary: '#A5B1C7', + textTertiary: '#6C7C98', + accentPrimary: '#5A8CFF', + accentSecondary: '#2EE6D6', + success: '#34D399', + warning: '#F59E0B', + danger: '#FF6E6E', + focusRing: '#735A8CFF', + overlayScrim: '#B8050812', + }, + + // ── NomGap Product Colors ────────────────────────────────────────── + nomgap: { + stageFed: '#FF9F43', + stageEarlyFast: '#FECA57', + stageFasted: '#48DBFB', + stageKetosis: '#5A8CFF', + stageDeepAutophagy: '#A66BFF', + stageExtended: '#FFD700', + autophagyMeter: '#5AE68C', + hydrationReminder: '#48DBFB', + electrolyteAlert: '#FF9F43', + safetyWarning: '#FF6E6E', + }, + + // ── Spacing (8pt grid) ────────────────────────────────────────────── + spacing: { + 0: 0, + 1: 4, + 2: 8, + 3: 12, + 4: 16, + 5: 20, + 6: 24, + 7: 28, + 8: 32, + 10: 40, + 12: 48, + 16: 64, + }, + + // ── Border Radius ─────────────────────────────────────────────────── + radius: { + xs: 8, + sm: 12, + md: 16, + lg: 20, + xl: 24, + pill: 999, + }, + + // ── Typography ───────────────────────────────────────────────────── + typography: { + fontFamily: { + display: 'System', + body: 'System', + mono: 'Courier', + }, + fontSize: { + xs: 12, + sm: 14, + md: 16, + lg: 18, + xl: 22, + '2xl': 28, + '3xl': 36, + }, + fontWeight: { + regular: '400', + medium: '500', + semibold: '600', + bold: '700', + }, + }, + + // ── Icon Sizes ────────────────────────────────────────────────────── + icon: { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 32, + '2xl': 48, + }, + + // ── Z-Index Layers ─────────────────────────────────────────────────── + zIndex: { + hidden: -1, + base: 0, + dropdown: 100, + sticky: 200, + fixed: 300, + overlay: 400, + modal: 500, + popover: 600, + toast: 700, + tooltip: 800, + }, + + // ── Opacity ───────────────────────────────────────────────────────── + opacity: { + '0': 0, + '10': 0.1, + '20': 0.2, + '30': 0.3, + '40': 0.4, + '50': 0.5, + '60': 0.6, + '70': 0.7, + '80': 0.8, + '90': 0.9, + '100': 1, + }, + + // ── Motion ──────────────────────────────────────────────────────────── + motion: { + instant: 70, + fast: 140, + base: 220, + slow: 320, + }, +} as const; + +export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/generated/tokens.css b/vendor/bytelyst/design-tokens/generated/tokens.css new file mode 100644 index 0000000..27d5710 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/tokens.css @@ -0,0 +1,78 @@ +/* Auto-generated from bytelyst.tokens.json — do not edit manually */ + +:root, +[data-theme="dark"] { + --ml-bg-canvas: #06070A; + --ml-bg-elevated: #0E1118; + --ml-surface-card: #121725; + --ml-surface-muted: #1A2335; + --ml-border-default: rgba(255,255,255,0.12); + --ml-border-strong: rgba(255,255,255,0.22); + --ml-text-primary: #EFF4FF; + --ml-text-secondary: #A5B1C7; + --ml-text-tertiary: #6C7C98; + --ml-accent-primary: #5A8CFF; + --ml-accent-secondary: #2EE6D6; + --ml-success: #34D399; + --ml-warning: #F59E0B; + --ml-danger: #FF6E6E; + --ml-focus-ring: rgba(90,140,255,0.45); + --ml-overlay-scrim: rgba(5,8,18,0.72); + + --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; + --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; + --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; + + --ml-fs-xs: 12px; + --ml-fs-sm: 14px; + --ml-fs-md: 16px; + --ml-fs-lg: 18px; + --ml-fs-xl: 22px; + --ml-fs-2xl: 28px; + --ml-fs-3xl: 36px; + + --ml-space-0: 0; + --ml-space-1: 4px; + --ml-space-2: 8px; + --ml-space-3: 12px; + --ml-space-4: 16px; + --ml-space-5: 20px; + --ml-space-6: 24px; + --ml-space-7: 28px; + --ml-space-8: 32px; + --ml-space-10: 40px; + --ml-space-12: 48px; + --ml-space-16: 64px; + + --ml-radius-xs: 8px; + --ml-radius-sm: 12px; + --ml-radius-md: 16px; + --ml-radius-lg: 20px; + --ml-radius-xl: 24px; + --ml-radius-pill: 999px; + + --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); + --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); + --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); + + --ml-motion-fast: 140ms; + --ml-motion-base: 220ms; + --ml-motion-slow: 320ms; + --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); +} + +[data-theme="light"] { + --ml-bg-canvas: #F6F8FC; + --ml-bg-elevated: #EEF2FA; + --ml-surface-card: #FFFFFF; + --ml-surface-muted: #F3F5FA; + --ml-border-default: rgba(14,19,32,0.12); + --ml-border-strong: rgba(14,19,32,0.24); + --ml-text-primary: #0E1320; + --ml-text-secondary: #55637A; + --ml-success: #13956A; + --ml-warning: #B87504; + --ml-danger: #D24242; + --ml-focus-ring: rgba(90,140,255,0.35); + --ml-overlay-scrim: rgba(10,13,23,0.5); +} diff --git a/vendor/bytelyst/design-tokens/generated/tokens.ts b/vendor/bytelyst/design-tokens/generated/tokens.ts new file mode 100644 index 0000000..6a79b38 --- /dev/null +++ b/vendor/bytelyst/design-tokens/generated/tokens.ts @@ -0,0 +1,386 @@ +// Auto-generated from bytelyst.tokens.json — do not edit manually + +export const tokens = { + "meta": { + "name": "ByteLyst Design Tokens", + "version": "1.1.0", + "updatedAt": "2026-03-03", + "scale": "8pt" + }, + "color": { + "palette": { + "neutral": { + "0": "#FFFFFF", + "50": "#F6F8FC", + "100": "#EEF2FA", + "200": "#DCE4F2", + "300": "#BFCBDE", + "400": "#92A1BA", + "500": "#6C7C98", + "600": "#55637A", + "700": "#3B455A", + "800": "#1A2335", + "900": "#0E1320", + "950": "#06070A" + }, + "brand": { + "blue": "#5A8CFF", + "cyan": "#2EE6D6", + "coral": "#FF6E6E", + "gold": "#FFD166", + "mint": "#34D399", + "warning": "#F59E0B", + "microsoftRed": "#F25022", + "microsoftGreen": "#7FBA00", + "microsoftBlue": "#00A4EF", + "microsoftYellow": "#FFB900", + "googleBlue": "#4285F4", + "googleGreen": "#34A853", + "googleYellow": "#FBBC05", + "googleRed": "#EA4335" + } + }, + "semantic": { + "dark": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "borderDefault": "rgba(255,255,255,0.12)", + "borderStrong": "rgba(255,255,255,0.22)", + "textPrimary": "#EFF4FF", + "textSecondary": "#A5B1C7", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "focusRing": "rgba(90,140,255,0.45)", + "overlayScrim": "rgba(5,8,18,0.72)" + }, + "light": { + "bgCanvas": "#F6F8FC", + "bgElevated": "#EEF2FA", + "surfaceCard": "#FFFFFF", + "surfaceMuted": "#F3F5FA", + "borderDefault": "rgba(14,19,32,0.12)", + "borderStrong": "rgba(14,19,32,0.24)", + "textPrimary": "#0E1320", + "textSecondary": "#55637A", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#13956A", + "warning": "#B87504", + "danger": "#D24242", + "focusRing": "rgba(90,140,255,0.35)", + "overlayScrim": "rgba(10,13,23,0.5)" + } + }, + "brain": { + "work": { + "from": "#5A8CFF", + "to": "#2EE6D6" + }, + "home": { + "from": "#FF6E6E", + "to": "#FFD166" + }, + "money": { + "from": "#34D399", + "to": "#2EE6D6" + }, + "health": { + "from": "#2EE6D6", + "to": "#9FE870" + }, + "global": { + "from": "#7D8FB4", + "to": "#A5B1C7" + } + }, + "jarvisjr": { + "accentPrimary": "#7C6BFF", + "accentSecondary": "#5AE6C8", + "accentVoice": "#FF6B8A", + "agentCoach": "#5A8CFF", + "agentLingua": "#FFB74D", + "agentSpark": "#E040FB", + "agentMentor": "#34D399", + "agentMirror": "#2EE6D6", + "agentOrator": "#FF9F43" + }, + "peakpulse": { + "activityHike": "#34D399", + "activitySki": "#5A8CFF", + "speedZoneSlow": "#34D399", + "speedZoneFast": "#FFD166", + "speedZoneDanger": "#FF6E6E", + "elevationGain": "#2EE6D6", + "elevationLoss": "#FF9F43", + "personalBest": "#FFD700", + "streakActive": "#34D399", + "streakBroken": "#FF6E6E" + }, + "chronomind": { + "urgencyCritical": "#FF6E6E", + "urgencyImportant": "#FFD166", + "urgencyStandard": "#5A8CFF", + "urgencyGentle": "#34D399", + "urgencyPassive": "#A5B1C7", + "focusMode": "#7C6BFF", + "pomodoroWork": "#34D399", + "pomodoroBreak": "#5A8CFF", + "cascadeWarning": "#FF9F43", + "timerComplete": "#34D399" + }, + "nomgap": { + "stageFed": "#FF9F43", + "stageEarlyFast": "#FECA57", + "stageFasted": "#48DBFB", + "stageKetosis": "#5A8CFF", + "stageDeepAutophagy": "#A66BFF", + "stageExtended": "#FFD700", + "autophagyMeter": "#5AE68C", + "hydrationReminder": "#48DBFB", + "electrolyteAlert": "#FF9F43", + "safetyWarning": "#FF6E6E" + }, + "lysnrai": { + "recordingActive": "#FF6E6E", + "recordingPaused": "#FFD166", + "processing": "#5A8CFF", + "transcribed": "#34D399", + "dictationMode": "#7C6BFF", + "commandMode": "#2EE6D6", + "hotkeyActive": "#FF6B8A" + }, + "flowmonk": { + "bg": "#07111F", + "surface": "#0F1B2D", + "surfaceElevated": "#152338", + "border": "#24344D", + "text": "#EFF4FF", + "textMuted": "#A8B4C8", + "primary": "#5A8CFF", + "accent": "#5AE68C", + "warning": "#F59E0B", + "zonework": "#5A8CFF", + "zonePersonal": "#5AE68C", + "zoneHealth": "#FF6B6B", + "zoneAdmin": "#FECA57", + "zoneLearning": "#A66BFF", + "urgentBadge": "#FF6E6E", + "scheduleEntry": "#5A8CFF", + "overflowWarning": "#F59E0B", + "recommendationInfo": "#5A8CFF", + "recommendationWarning": "#F59E0B", + "recommendationCritical": "#FF6E6E" + }, + "actiontrail": { + "bg": "#07111F", + "surface": "#0F1B2D", + "surfaceElevated": "#152338", + "border": "#24344D", + "text": "#EFF4FF", + "textMuted": "#A8B4C8", + "primary": "#5A8CFF", + "accent": "#5AE68C", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "riskLow": "#5AE68C", + "riskMedium": "#F59E0B", + "riskHigh": "#FF8C42", + "riskCritical": "#FF6E6E", + "statusPending": "#F59E0B", + "statusApplied": "#5AE68C", + "statusRejected": "#FF6E6E", + "statusReverted": "#A66BFF" + }, + "notelett": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "focusRing": "#5A8CFF", + "agentAction": "#A66BFF", + "draftNote": "#FFD166", + "linkedNote": "#2EE6D6", + "taskPending": "#F59E0B", + "taskComplete": "#34D399" + }, + "localmemgpt": { + "bgPrimary": "#0A0A0A", + "bgSecondary": "#141414", + "bgTertiary": "#1E1E1E", + "bgHover": "#252525", + "bgInput": "#1A1A1A", + "border": "#2A2A2A", + "textPrimary": "#F0F0F0", + "textSecondary": "#999999", + "textMuted": "#666666", + "accent": "#6366F1", + "accentHover": "#818CF8", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444" + }, + "localllmlab": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "borderSubtle": "#1E293B", + "borderDefault": "#2A3654", + "textPrimary": "#EFF4FF", + "textSecondary": "#A5B1C7", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "purple": "#A78BFA" + } + }, + "typography": { + "fontFamily": { + "display": "'Space Grotesk', 'SF Pro Display', sans-serif", + "body": "'DM Sans', 'SF Pro Text', sans-serif", + "mono": "'IBM Plex Mono', 'SF Mono', monospace" + }, + "fontWeight": { + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "fontSize": { + "xs": 12, + "sm": 14, + "md": 16, + "lg": 18, + "xl": 22, + "2xl": 28, + "3xl": 36 + }, + "lineHeight": { + "tight": 1.2, + "normal": 1.45, + "relaxed": 1.65 + }, + "letterSpacing": { + "tight": -0.02, + "normal": 0, + "wide": 0.02 + } + }, + "spacing": { + "0": 0, + "1": 4, + "2": 8, + "3": 12, + "4": 16, + "5": 20, + "6": 24, + "7": 28, + "8": 32, + "10": 40, + "12": 48, + "16": 64 + }, + "radius": { + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "pill": 999 + }, + "elevation": { + "none": "0 0 0 rgba(0,0,0,0)", + "sm": "0 4px 12px rgba(0,0,0,0.12)", + "md": "0 12px 28px rgba(0,0,0,0.18)", + "lg": "0 20px 48px rgba(0,0,0,0.24)" + }, + "motion": { + "duration": { + "instant": 70, + "fast": 140, + "base": 220, + "slow": 320 + }, + "easing": { + "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", + "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", + "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" + } + }, + "breakpoints": { + "mobile": 0, + "tablet": 768, + "desktop": 1200, + "wide": 1440 + }, + "layout": { + "maxContentWidth": 1280, + "mobileGutter": 16, + "tabletGutter": 24, + "desktopGutter": 32, + "touchTargetMin": 44 + }, + "zIndex": { + "hidden": -1, + "base": 0, + "dropdown": 100, + "sticky": 200, + "fixed": 300, + "overlay": 400, + "modal": 500, + "popover": 600, + "toast": 700, + "tooltip": 800 + }, + "icon": { + "xs": 12, + "sm": 16, + "md": 20, + "lg": 24, + "xl": 32, + "2xl": 48 + }, + "grid": { + "columns": 12, + "gutter": 24, + "maxWidth": 1200, + "breakpoints": { + "xs": 0, + "sm": 576, + "md": 768, + "lg": 992, + "xl": 1200, + "xxl": 1400 + } + }, + "opacity": { + "0": 0, + "10": 0.1, + "20": 0.2, + "30": 0.3, + "40": 0.4, + "50": 0.5, + "60": 0.6, + "70": 0.7, + "80": 0.8, + "90": 0.9, + "100": 1 + } +} as const; + +export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/package.json b/vendor/bytelyst/design-tokens/package.json new file mode 100644 index 0000000..55a6e3a --- /dev/null +++ b/vendor/bytelyst/design-tokens/package.json @@ -0,0 +1,44 @@ +{ + "name": "@bytelyst/design-tokens", + "version": "0.1.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./tokens.json": "./tokens/bytelyst.tokens.json", + "./generated/tokens": "./generated/tokens.ts", + "./css": "./generated/tokens.css", + "./css/chronomind": "./generated/chronomind.css", + "./css/jarvisjr": "./generated/jarvisjr.css", + "./css/nomgap": "./generated/nomgap.css", + "./css/actiontrail": "./generated/actiontrail.css", + "./css/flowmonk": "./generated/flowmonk.css", + "./css/notelett": "./generated/notelett.css", + "./css/localmemgpt": "./generated/localmemgpt.css", + "./css/localllmlab": "./generated/localllmlab.css", + "./css/lysnrai": "./generated/lysnrai.css", + "./css/peakpulse": "./generated/peakpulse.css", + "./css/mindlyst": "./generated/mindlyst.css" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "tokens", + "generated", + "scripts" + ], + "scripts": { + "build": "tsc", + "generate": "tsx scripts/generate.ts", + "test": "vitest run --pool forks" + }, + "devDependencies": { + "tsx": "^4.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts b/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts new file mode 100644 index 0000000..55e5ba6 --- /dev/null +++ b/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts @@ -0,0 +1,143 @@ +/** + * React Native token generator for Expo/NomGap + * Reads bytelyst.tokens.json, outputs RN StyleSheet-compatible tokens + * + * Usage: tsx scripts/generate-react-native.ts + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); +const outDir = resolve(__dirname, '../generated/react-native'); + +mkdirSync(outDir, { recursive: true }); + +const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); + +// ── Helpers ─────────────────────────────────────────────────────────── + +function generateReactNative(): string { + const lines: string[] = [ + '/**', + ' * React Native Design Tokens — Auto-generated from bytelyst.tokens.json', + ' * Do not edit manually. Run: tsx scripts/generate-react-native.ts', + ' */', + '', + 'export const tokens = {', + '', + ' // ── Semantic Colors (Dark Theme) ──────────────────────────────────', + ' colors: {', + ]; + + // Semantic dark colors + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` ${key}: '${value}',`); + } else if (typeof value === 'string' && value.startsWith('rgba')) { + // Convert rgba to hex8 for React Native + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); + if (match) { + const [, r, g, b, a] = match; + const alpha = Math.round(parseFloat(a) * 255) + .toString(16) + .padStart(2, '0'); + const hex = + `#${alpha}${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`.toUpperCase(); + lines.push(` ${key}: '${hex}',`); + } + } + } + lines.push(' },', ''); + + // NomGap-specific colors + lines.push(' // ── NomGap Product Colors ──────────────────────────────────────────'); + lines.push(' nomgap: {'); + for (const [key, value] of Object.entries(tokens.color.nomgap)) { + lines.push(` ${key}: '${value}',`); + } + lines.push(' },', ''); + + // Spacing + lines.push(' // ── Spacing (8pt grid) ──────────────────────────────────────────────'); + lines.push(' spacing: {'); + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Radius + lines.push(' // ── Border Radius ───────────────────────────────────────────────────'); + lines.push(' radius: {'); + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Typography + lines.push(' // ── Typography ─────────────────────────────────────────────────────'); + lines.push(' typography: {'); + lines.push(' fontFamily: {'); + for (const key of Object.keys(tokens.typography.fontFamily)) { + // Use system fonts for React Native + const systemFont = key === 'display' ? 'System' : key === 'mono' ? 'Courier' : 'System'; + lines.push(` ${key}: '${systemFont}',`); + } + lines.push(' },'); + lines.push(' fontSize: {'); + for (const [key, value] of Object.entries(tokens.typography.fontSize)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },'); + lines.push(' fontWeight: {'); + for (const [key, value] of Object.entries(tokens.typography.fontWeight)) { + lines.push(` ${key}: '${value}',`); + } + lines.push(' },'); + lines.push(' },', ''); + + // Icon sizes + lines.push(' // ── Icon Sizes ──────────────────────────────────────────────────────'); + lines.push(' icon: {'); + for (const [key, value] of Object.entries(tokens.icon)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Z-index (for RN zIndex style prop) + lines.push(' // ── Z-Index Layers ───────────────────────────────────────────────────'); + lines.push(' zIndex: {'); + for (const [key, value] of Object.entries(tokens.zIndex)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Opacity + lines.push(' // ── Opacity ─────────────────────────────────────────────────────────'); + lines.push(' opacity: {'); + for (const [key, value] of Object.entries(tokens.opacity)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Motion (duration in ms) + lines.push(' // ── Motion ────────────────────────────────────────────────────────────'); + lines.push(' motion: {'); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + lines.push('} as const;', ''); + lines.push(''); + lines.push('export type Tokens = typeof tokens;', ''); + + return lines.join('\n'); +} + +// ── Write ────────────────────────────────────────────────────────── +writeFileSync(resolve(outDir, 'tokens.ts'), generateReactNative()); +// eslint-disable-next-line no-console +console.log('Generated React Native tokens in generated/react-native/'); diff --git a/vendor/bytelyst/design-tokens/scripts/generate.ts b/vendor/bytelyst/design-tokens/scripts/generate.ts new file mode 100644 index 0000000..3675d12 --- /dev/null +++ b/vendor/bytelyst/design-tokens/scripts/generate.ts @@ -0,0 +1,750 @@ +/** + * Token generator — reads bytelyst.tokens.json, outputs 4 platform formats: + * 1. CSS custom properties (tokens.css) + * 2. TypeScript constants (tokens.ts) + * 3. Kotlin object (MindLystTokens.kt) — for KMP shared module + * 4. Swift structs (MindLystTheme.swift) — for iOS SwiftUI + * + * Output conventions match the hand-written originals in learning_multimodal_memory_agents: + * - Kotlin: SCREAMING_SNAKE_CASE, Palette/Dark/Light/BrainGradient/Typography/Motion/Layout + * - Swift: Color(hex: UInt), dark/light prefixes, Gradient(colors:), MindLystMotion, Color ext + * - CSS: [data-theme], --ml-fs-*, --ml-elevation-*, --ml-motion-* + * + * Usage: tsx scripts/generate.ts + */ + +/* eslint-disable no-console -- This generator is a CLI; console output confirms generated artifacts. */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); +const outDir = resolve(__dirname, '../generated'); + +mkdirSync(outDir, { recursive: true }); + +const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); + +// ── Product CSS mapping ───────────────────────────────────────────── +const PRODUCT_CSS_MAP: Record = { + mindlyst: { prefix: 'ml', colorsKey: 'brain' }, + chronomind: { prefix: 'cm', colorsKey: 'chronomind' }, + jarvisjr: { prefix: 'jj', colorsKey: 'jarvisjr' }, + nomgap: { prefix: 'ng', colorsKey: 'nomgap' }, + actiontrail: { prefix: 'at', colorsKey: 'actiontrail' }, + flowmonk: { prefix: 'fm', colorsKey: 'flowmonk' }, + notelett: { prefix: 'nl', colorsKey: 'notelett' }, + localmemgpt: { prefix: 'lmg', colorsKey: 'localmemgpt' }, + localllmlab: { prefix: 'llm', colorsKey: 'localllmlab' }, + lysnrai: { prefix: 'lys', colorsKey: 'lysnrai' }, + peakpulse: { prefix: 'pp', colorsKey: 'peakpulse' }, +}; + +// ── Helpers ────────────────────────────────────────────────────────── + +function camelToKebab(str: string): string { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +} + +function camelToScreamingSnake(str: string): string { + return str.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); +} + +function hexToUInt(hex: string): string { + return `0x${hex.replace('#', '').toUpperCase()}`; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +// ── 1. CSS ─────────────────────────────────────────────────────────── +function generateCSS(): string { + const lines: string[] = [ + '/* Auto-generated from bytelyst.tokens.json — do not edit manually */', + '', + ':root,', + '[data-theme="dark"] {', + ]; + + // Semantic colors (dark theme as default) + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + lines.push(` --ml-${camelToKebab(key)}: ${value};`); + } + lines.push(''); + + // Typography + for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { + // Swap single quotes → double quotes for CSS + const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; + lines.push(` --ml-font-${key}: ${cssVal};`); + } + lines.push(''); + + // Font sizes (--ml-fs-* to match existing convention) + for (const [key, value] of Object.entries(tokens.typography.fontSize)) { + lines.push(` --ml-fs-${key}: ${value}px;`); + } + lines.push(''); + + // Spacing + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` --ml-space-${key}: ${value === 0 ? '0' : `${value}px`};`); + } + lines.push(''); + + // Radius + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` --ml-radius-${key}: ${value}px;`); + } + lines.push(''); + + // Elevation (--ml-elevation-* to match existing) + for (const [key, value] of Object.entries(tokens.elevation)) { + if (key === 'none') continue; + lines.push(` --ml-elevation-${key}: ${value};`); + } + lines.push(''); + + // Motion + for (const [key, value] of Object.entries(tokens.motion.duration)) { + if (key === 'instant') continue; // not used in CSS + lines.push(` --ml-motion-${key}: ${value}ms;`); + } + lines.push(` --ml-easing-standard: ${tokens.motion.easing.standard};`); + + lines.push('}', ''); + + // Light theme overrides + lines.push('[data-theme="light"] {'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + // Only emit overrides where light differs from dark + const darkVal = tokens.color.semantic.dark[key]; + if (value !== darkVal) { + lines.push(` --ml-${camelToKebab(key)}: ${value};`); + } + } + lines.push('}', ''); + + return lines.join('\n'); +} + +// ── 2. TypeScript ──────────────────────────────────────────────────── +function generateTS(): string { + return [ + '// Auto-generated from bytelyst.tokens.json — do not edit manually', + '', + `export const tokens = ${JSON.stringify(tokens, null, 2)} as const;`, + '', + 'export type Tokens = typeof tokens;', + '', + ].join('\n'); +} + +// ── 3. Kotlin ──────────────────────────────────────────────────────── +function generateKotlin(): string { + const lines: string[] = [ + '// Auto-generated from bytelyst.tokens.json — do not edit manually', + 'package com.mindlyst.shared.theme', + '', + '/**', + ' * Cross-platform design tokens from bytelyst.tokens.json.', + ' * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI).', + ' */', + 'object MindLystTokens {', + '', + ]; + + // ── Palette + lines.push(' // ── Color Palette ────────────────────────────────────────────────'); + lines.push(' object Palette {'); + for (const [key, value] of Object.entries(tokens.color.palette.neutral)) { + lines.push( + ` const val NEUTRAL_${key} = 0xFF${(value as string).replace('#', '').toUpperCase()}` + ); + } + lines.push(''); + for (const [key, value] of Object.entries(tokens.color.palette.brand)) { + lines.push( + ` const val ${key.toUpperCase()} = 0xFF${(value as string).replace('#', '').toUpperCase()}` + ); + } + lines.push(' }', ''); + + // ── Dark semantic + lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); + lines.push(' object Dark {'); + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push( + ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` + ); + } + } + lines.push(' }', ''); + + // ── Light semantic + lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); + lines.push(' object Light {'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push( + ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` + ); + } + } + lines.push(' }', ''); + + // ── Brain gradients + lines.push(' // ── Brain Identity Gradients ─────────────────────────────────────'); + lines.push(' data class BrainGradient(val from: Long, val to: Long)'); + lines.push(''); + for (const [name, grad] of Object.entries(tokens.color.brain) as [ + string, + { from: string; to: string }, + ][]) { + lines.push( + ` val BRAIN_${name.toUpperCase()} = BrainGradient(from = 0xFF${grad.from.replace('#', '').toUpperCase()}, to = 0xFF${grad.to.replace('#', '').toUpperCase()})` + ); + } + lines.push(''); + + // ── Spacing + lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); + lines.push(' object Spacing {'); + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` const val X${key} = ${value}`); + } + lines.push(' }', ''); + + // ── Radius + lines.push(' // ── Radius ───────────────────────────────────────────────────────'); + lines.push(' object Radius {'); + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` const val ${key.toUpperCase()} = ${value}`); + } + lines.push(' }', ''); + + // ── Typography + lines.push(' // ── Typography ───────────────────────────────────────────────────'); + lines.push(' object Typography {'); + for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { + // Extract just the primary font name (first in the list) + const fontName = + typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; + lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); + } + lines.push(''); + for (const [key, value] of Object.entries(tokens.typography.fontSize)) { + const sizeKey = key.toUpperCase().replace('-', ''); + lines.push(` const val SIZE_${sizeKey} = ${value}`); + } + lines.push(' }', ''); + + // ── Motion + lines.push(' // ── Motion ───────────────────────────────────────────────────────'); + lines.push(' object Motion {'); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + lines.push(` const val ${key.toUpperCase()} = ${value}`); + } + lines.push(' }', ''); + + // ── Layout + lines.push(' // ── Layout ───────────────────────────────────────────────────────'); + lines.push(' object Layout {'); + lines.push(` const val TOUCH_TARGET_MIN = ${tokens.layout.touchTargetMin}`); + lines.push(` const val MOBILE_GUTTER = ${tokens.layout.mobileGutter}`); + lines.push(` const val MAX_WIDTH = ${tokens.layout.maxContentWidth}`); + lines.push(' }'); + + lines.push('}', ''); + return lines.join('\n'); +} + +// ── 4. Swift ───────────────────────────────────────────────────────── +function generateSwift(): string { + const lines: string[] = [ + '// Auto-generated from bytelyst.tokens.json — do not edit manually', + 'import SwiftUI', + '', + '// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens)', + '// These values mirror MindLystTokens.kt exactly.', + '', + 'struct MindLystColors {', + ]; + + // Dark colors + lines.push(' // Dark'); + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` static let dark${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); + } else if (typeof value === 'string' && value.startsWith('rgba')) { + // border/overlay → special handling + if (key === 'borderDefault') { + lines.push(' static let darkBorder = Color.white.opacity(0.12)'); + } + } + } + lines.push(''); + + // Light colors + lines.push(' // Light'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + if (typeof value === 'string' && value.startsWith('#')) { + if (value === '#FFFFFF') { + lines.push(` static let light${capitalize(key)} = Color.white`); + } else { + lines.push(` static let light${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); + } + } + } + lines.push(''); + + // Brain gradients + lines.push(' // Brain Gradients'); + for (const [name, grad] of Object.entries(tokens.color.brain) as [ + string, + { from: string; to: string }, + ][]) { + lines.push( + ` static let brain${capitalize(name)} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` + ); + } + lines.push('}', ''); + + // Spacing + lines.push('struct MindLystSpacing {'); + for (const [key, value] of Object.entries(tokens.spacing)) { + const pad = key.length === 1 ? ' ' : ''; + lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); + } + lines.push('}', ''); + + // Radius + lines.push('struct MindLystRadius {'); + for (const [key, value] of Object.entries(tokens.radius)) { + const pad = key.length < 4 ? ' '.repeat(4 - key.length) : ''; + lines.push(` static let ${key}:${pad} CGFloat = ${value}`); + } + lines.push('}', ''); + + // Motion (durations in seconds) + lines.push('struct MindLystMotion {'); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + const seconds = (value as number) / 1000; + const pad = key.length < 7 ? ' '.repeat(7 - key.length) : ''; + lines.push(` static let ${key}:${pad} Double = ${seconds.toFixed(2)}`); + } + lines.push('}', ''); + + // Color hex extension + lines.push('// MARK: - Color Hex Extension'); + lines.push(''); + lines.push('extension Color {'); + lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); + lines.push(' self.init('); + lines.push(' .sRGB,'); + lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); + lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); + lines.push(' blue: Double(hex & 0xFF) / 255.0,'); + lines.push(' opacity: alpha'); + lines.push(' )'); + lines.push(' }'); + lines.push('}', ''); + + return lines.join('\n'); +} + +// ── 5. Per-product CSS ────────────────────────────────────────────── +function generateProductCSS(productId: string, prefix: string, colorsKey: string): string { + const productColors = tokens.color[colorsKey]; + if (!productColors) return `/* No palette found for ${productId} (color.${colorsKey}) */\n`; + + const lines: string[] = [ + `/* Auto-generated ${productId} tokens from bytelyst.tokens.json — do not edit manually */`, + '', + ':root {', + ]; + + // Semantic colors (dark as default) — shared across all products + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); + } + lines.push(''); + + // Product-specific colors + lines.push(` /* ${productId} product colors */`); + for (const [key, value] of Object.entries(productColors)) { + if (typeof value === 'string') { + lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); + } else if (typeof value === 'object' && value !== null && 'from' in value && 'to' in value) { + const grad = value as unknown as { from: string; to: string }; + lines.push(` --${prefix}-${camelToKebab(key)}-from: ${grad.from};`); + lines.push(` --${prefix}-${camelToKebab(key)}-to: ${grad.to};`); + } + } + lines.push(''); + + // Typography + for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { + const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; + lines.push(` --${prefix}-font-${key}: ${cssVal};`); + } + lines.push(''); + + // Font sizes + for (const [key, value] of Object.entries(tokens.typography.fontSize)) { + lines.push(` --${prefix}-fs-${key}: ${value}px;`); + } + lines.push(''); + + // Spacing + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` --${prefix}-space-${key}: ${value === 0 ? '0' : `${value}px`};`); + } + lines.push(''); + + // Radius + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` --${prefix}-radius-${key}: ${value}px;`); + } + lines.push(''); + + // Elevation + for (const [key, value] of Object.entries(tokens.elevation)) { + if (key === 'none') continue; + lines.push(` --${prefix}-elevation-${key}: ${value};`); + } + lines.push(''); + + // Motion + for (const [key, value] of Object.entries(tokens.motion.duration)) { + if (key === 'instant') continue; + lines.push(` --${prefix}-motion-${key}: ${value}ms;`); + } + lines.push(` --${prefix}-easing-standard: ${tokens.motion.easing.standard};`); + + lines.push('}', ''); + + // Light theme overrides + lines.push('[data-theme="light"] {'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + const darkVal = (tokens.color.semantic.dark as Record)[key]; + if (value !== darkVal) { + lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); + } + } + lines.push('}', ''); + + return lines.join('\n'); +} + +// ── Product native mapping (products with iOS/Android apps) ───────── +interface ProductNativeConfig { + colorsKey: string; + swiftEnum: string; // e.g. 'PeakPulseColors' + kotlinObject: string; // e.g. 'PeakPulseTokens' + kotlinPackage: string; // e.g. 'com.peakpulse.theme' + swiftFile: string; // e.g. 'PeakPulseTheme.swift' + kotlinFile: string; // e.g. 'PeakPulseTokens.kt' +} + +const PRODUCT_NATIVE_MAP: Record = { + chronomind: { + colorsKey: 'chronomind', + swiftEnum: 'CMColors', + kotlinObject: 'ChronoMindTokens', + kotlinPackage: 'com.chronomind.app.theme', + swiftFile: 'ChronoMindTheme.generated.swift', + kotlinFile: 'ChronoMindTokens.generated.kt', + }, + jarvisjr: { + colorsKey: 'jarvisjr', + swiftEnum: 'JarvisJrColors', + kotlinObject: 'JarvisJrTokens', + kotlinPackage: 'com.jarvisjr.app.theme', + swiftFile: 'JarvisJrTheme.generated.swift', + kotlinFile: 'JarvisJrTokens.generated.kt', + }, + peakpulse: { + colorsKey: 'peakpulse', + swiftEnum: 'PeakPulseColors', + kotlinObject: 'PeakPulseTokens', + kotlinPackage: 'com.peakpulse.theme', + swiftFile: 'PeakPulseTheme.generated.swift', + kotlinFile: 'PeakPulseTokens.generated.kt', + }, + lysnrai: { + colorsKey: 'lysnrai', + swiftEnum: 'LysnrAIColors', + kotlinObject: 'LysnrAITokens', + kotlinPackage: 'com.saravana.lysnrai.theme', + swiftFile: 'LysnrAITheme.generated.swift', + kotlinFile: 'LysnrAITokens.generated.kt', + }, + nomgap: { + colorsKey: 'nomgap', + swiftEnum: 'NomGapColors', + kotlinObject: 'NomGapTokens', + kotlinPackage: 'com.nomgap.theme', + swiftFile: 'NomGapTheme.generated.swift', + kotlinFile: 'NomGapTokens.generated.kt', + }, + actiontrail: { + colorsKey: 'actiontrail', + swiftEnum: 'ActionTrailColors', + kotlinObject: 'ActionTrailTokens', + kotlinPackage: 'com.actiontrail.theme', + swiftFile: 'ActionTrailTheme.generated.swift', + kotlinFile: 'ActionTrailTokens.generated.kt', + }, + flowmonk: { + colorsKey: 'flowmonk', + swiftEnum: 'FlowMonkColors', + kotlinObject: 'FlowMonkTokens', + kotlinPackage: 'com.flowmonk.theme', + swiftFile: 'FlowMonkTheme.generated.swift', + kotlinFile: 'FlowMonkTokens.generated.kt', + }, + notelett: { + colorsKey: 'notelett', + swiftEnum: 'NoteLettColors', + kotlinObject: 'NoteLettTokens', + kotlinPackage: 'com.notelett.theme', + swiftFile: 'NoteLettTheme.generated.swift', + kotlinFile: 'NoteLettTokens.generated.kt', + }, + localmemgpt: { + colorsKey: 'localmemgpt', + swiftEnum: 'LocalMemGPTColors', + kotlinObject: 'LocalMemGPTTokens', + kotlinPackage: 'com.localmemgpt.theme', + swiftFile: 'LocalMemGPTTheme.generated.swift', + kotlinFile: 'LocalMemGPTTokens.generated.kt', + }, + localllmlab: { + colorsKey: 'localllmlab', + swiftEnum: 'LocalLLMLabColors', + kotlinObject: 'LocalLLMLabTokens', + kotlinPackage: 'com.localllmlab.theme', + swiftFile: 'LocalLLMLabTheme.generated.swift', + kotlinFile: 'LocalLLMLabTokens.generated.kt', + }, +}; + +// ── 6. Per-product Swift ───────────────────────────────────────────── +function generateProductSwift(productId: string, config: ProductNativeConfig): string { + const productColors = tokens.color[config.colorsKey]; + const lines: string[] = [ + `// Auto-generated from bytelyst.tokens.json — do not edit manually.`, + `// Product: ${productId}`, + `// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts`, + '', + 'import SwiftUI', + '', + `enum ${config.swiftEnum} {`, + ]; + + // Semantic dark colors + lines.push(' // MARK: - Semantic (Dark Theme)'); + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); + } + } + lines.push(''); + + // Product-specific colors + if (productColors) { + lines.push(` // MARK: - ${capitalize(productId)} Product Colors`); + for (const [key, value] of Object.entries(productColors)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); + } else if (typeof value === 'object' && value !== null && 'from' in value) { + const grad = value as { from: string; to: string }; + lines.push( + ` static let ${key} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` + ); + } + } + lines.push(''); + } + + lines.push('}', ''); + + // Semantic light colors + lines.push(`enum ${config.swiftEnum}Light {`); + lines.push(' // MARK: - Semantic (Light Theme)'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); + } + } + lines.push('}', ''); + + // Spacing + lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Spacing {`); + for (const [key, value] of Object.entries(tokens.spacing)) { + const pad = key.length === 1 ? ' ' : ''; + lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); + } + lines.push('}', ''); + + // Radius + lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Radius {`); + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` static let ${key}: CGFloat = ${value}`); + } + lines.push('}', ''); + + // Motion + lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Motion {`); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + const seconds = (value as number) / 1000; + lines.push(` static let ${key}: Double = ${seconds.toFixed(2)}`); + } + lines.push('}', ''); + + // Color hex extension (only include if not already in project) + lines.push('// MARK: - Color Hex Extension (import if not already defined)'); + lines.push(''); + lines.push('extension Color {'); + lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); + lines.push(' self.init('); + lines.push(' .sRGB,'); + lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); + lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); + lines.push(' blue: Double(hex & 0xFF) / 255.0,'); + lines.push(' opacity: alpha'); + lines.push(' )'); + lines.push(' }'); + lines.push('}', ''); + + return lines.join('\n'); +} + +// ── 7. Per-product Kotlin ──────────────────────────────────────────── +function generateProductKotlin(productId: string, config: ProductNativeConfig): string { + const productColors = tokens.color[config.colorsKey]; + const lines: string[] = [ + '// Auto-generated from bytelyst.tokens.json — do not edit manually.', + `// Product: ${productId}`, + '// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts', + `package ${config.kotlinPackage}`, + '', + `object ${config.kotlinObject} {`, + '', + ]; + + // Semantic dark + lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); + lines.push(' object Dark {'); + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push( + ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` + ); + } + } + lines.push(' }', ''); + + // Semantic light + lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); + lines.push(' object Light {'); + for (const [key, value] of Object.entries(tokens.color.semantic.light)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push( + ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` + ); + } + } + lines.push(' }', ''); + + // Product-specific colors + if (productColors) { + lines.push(` // ── ${capitalize(productId)} Product Colors ───────────────────────────────`); + lines.push(' object Product {'); + for (const [key, value] of Object.entries(productColors)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push( + ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` + ); + } else if (typeof value === 'object' && value !== null && 'from' in value) { + const grad = value as { from: string; to: string }; + lines.push( + ` const val ${camelToScreamingSnake(key)}_FROM = 0xFF${grad.from.replace('#', '').toUpperCase()}` + ); + lines.push( + ` const val ${camelToScreamingSnake(key)}_TO = 0xFF${grad.to.replace('#', '').toUpperCase()}` + ); + } + } + lines.push(' }', ''); + } + + // Spacing + lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); + lines.push(' object Spacing {'); + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` const val X${key} = ${value}`); + } + lines.push(' }', ''); + + // Radius + lines.push(' // ── Radius ───────────────────────────────────────────────────────'); + lines.push(' object Radius {'); + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` const val ${key.toUpperCase()} = ${value}`); + } + lines.push(' }', ''); + + // Typography + lines.push(' // ── Typography ───────────────────────────────────────────────────'); + lines.push(' object Typography {'); + for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { + const fontName = + typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; + lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); + } + lines.push(' }', ''); + + // Motion + lines.push(' // ── Motion ───────────────────────────────────────────────────────'); + lines.push(' object Motion {'); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + lines.push(` const val ${key.toUpperCase()} = ${value}`); + } + lines.push(' }'); + + lines.push('}', ''); + return lines.join('\n'); +} + +// ── Write all ──────────────────────────────────────────────────────── +// Shared semantic tokens (backward compatible) +writeFileSync(resolve(outDir, 'tokens.css'), generateCSS()); +writeFileSync(resolve(outDir, 'tokens.ts'), generateTS()); +writeFileSync(resolve(outDir, 'MindLystTokens.kt'), generateKotlin()); +writeFileSync(resolve(outDir, 'MindLystTheme.swift'), generateSwift()); + +// Per-product CSS +for (const [productId, config] of Object.entries(PRODUCT_CSS_MAP)) { + const css = generateProductCSS(productId, config.prefix, config.colorsKey); + writeFileSync(resolve(outDir, `${productId}.css`), css); +} + +// Per-product Swift + Kotlin +const nativeDir = resolve(outDir, 'native'); +mkdirSync(nativeDir, { recursive: true }); +for (const [productId, config] of Object.entries(PRODUCT_NATIVE_MAP)) { + const swift = generateProductSwift(productId, config); + writeFileSync(resolve(nativeDir, config.swiftFile), swift); + const kotlin = generateProductKotlin(productId, config); + writeFileSync(resolve(nativeDir, config.kotlinFile), kotlin); +} + +console.log( + `Generated 4 shared + ${Object.keys(PRODUCT_CSS_MAP).length} product CSS + ${Object.keys(PRODUCT_NATIVE_MAP).length * 2} native token files in generated/` +); diff --git a/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs b/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs new file mode 100755 index 0000000..447852e --- /dev/null +++ b/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Token coverage report — analyzes how well a product uses design tokens + * + * Usage: node scripts/token-coverage.js + */ + +const { readFileSync, readdirSync, statSync } = require('fs'); +const { join, resolve } = require('path'); + +const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; +const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; + +const TOKEN_PATTERNS = { + web: { + cssVar: /--ml-[a-z-]+/g, + tokensImport: /@bytelyst\/design-tokens/, + }, + ios: { + mindLystColors: /MindLystColors\./g, + colorExtension: /Color\(hex:/g, + }, + kmp: { + mindLystTokens: /MindLystTokens\./g, + }, +}; + +function findFiles(dir, files = []) { + try { + const items = readdirSync(dir); + for (const item of items) { + const fullPath = join(dir, item); + if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + findFiles(fullPath, files); + } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { + files.push(fullPath); + } + } + } catch { + // Directory might not exist + } + return files; +} + +function detectPlatform(files) { + const hasSwift = files.some(f => f.endsWith('.swift')); + const hasKotlin = files.some(f => f.endsWith('.kt')); + const hasTSX = files.some(f => f.endsWith('.tsx')); + + if (hasSwift) return 'ios'; + if (hasKotlin) return 'kmp'; + if (hasTSX) return 'web'; + return 'unknown'; +} + +function analyzeCoverage(files, platform) { + let tokenUsages = 0; + let hardcodedColors = 0; + let filesUsingTokens = 0; + let filesWithHardcoded = 0; + + const patterns = TOKEN_PATTERNS[platform] || {}; + + for (const file of files) { + const content = readFileSync(file, 'utf-8'); + let hasTokens = false; + let hasHardcoded = false; + + // Check token usage + if (patterns.cssVar) { + const matches = content.match(patterns.cssVar); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + if (patterns.mindLystColors) { + const matches = content.match(patterns.mindLystColors); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + if (patterns.mindLystTokens) { + const matches = content.match(patterns.mindLystTokens); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + + // Check hardcoded colors + const colorMatches = content.match(/#[0-9A-Fa-f]{6}\b/g); + if (colorMatches) { + // Filter out likely non-color hex (like #FFFFFF in different contexts) + const likelyColors = colorMatches.filter(c => { + const hex = c.slice(1); + // Skip pure grays that might be intentional + if (hex[0] === hex[2] && hex[2] === hex[4]) return false; + return true; + }); + + if (likelyColors.length > 0) { + hardcodedColors += likelyColors.length; + hasHardcoded = true; + } + } + + if (hasTokens) filesUsingTokens++; + if (hasHardcoded) filesWithHardcoded++; + } + + return { + tokenUsages, + hardcodedColors, + filesUsingTokens, + filesWithHardcoded, + totalFiles: files.length, + }; +} + +function main() { + const targetPath = process.argv[2]; + if (!targetPath) { + console.log('Usage: node scripts/token-coverage.js '); + console.log(''); + console.log('Examples:'); + console.log(' node scripts/token-coverage.js ../../mindlyst-native/iosApp'); + console.log(' node scripts/token-coverage.js ../../learning_ai_clock/web/src'); + process.exit(1); + } + + const absolutePath = resolve(targetPath); + console.log(`📊 Analyzing token coverage for ${absolutePath}\n`); + + const files = findFiles(absolutePath); + const platform = detectPlatform(files); + + console.log(`Detected platform: ${platform}`); + console.log(`Total files: ${files.length}\n`); + + if (files.length === 0) { + console.log('❌ No source files found'); + process.exit(1); + } + + const coverage = analyzeCoverage(files, platform); + + console.log(`${'='.repeat(50)}`); + console.log('📈 Coverage Report'); + console.log(`${'='.repeat(50)}`); + console.log( + `Files using tokens: ${coverage.filesUsingTokens}/${coverage.totalFiles} (${((coverage.filesUsingTokens / coverage.totalFiles) * 100).toFixed(1)}%)` + ); + console.log( + `Files with hardcoded: ${coverage.filesWithHardcoded}/${coverage.totalFiles} (${((coverage.filesWithHardcoded / coverage.totalFiles) * 100).toFixed(1)}%)` + ); + console.log(`Token usages: ${coverage.tokenUsages}`); + console.log(`Hardcoded colors: ${coverage.hardcodedColors}`); + console.log(`${'='.repeat(50)}`); + + const tokenRatio = coverage.tokenUsages + coverage.hardcodedColors; + const tokenPercentage = + tokenRatio > 0 ? ((coverage.tokenUsages / tokenRatio) * 100).toFixed(1) : 'N/A'; + + console.log(`\n🎯 Token Adoption: ${tokenPercentage}%`); + + if (coverage.hardcodedColors > 0) { + console.log(`\n⚠️ Found ${coverage.hardcodedColors} hardcoded colors.`); + console.log(' Run validate-tokens.js for details.'); + } + + if (coverage.filesUsingTokens === 0) { + console.log('\n❌ No token usage detected. Product needs token integration.'); + process.exit(1); + } + + console.log('\n✅ Coverage analysis complete.'); +} + +main(); diff --git a/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs b/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs new file mode 100755 index 0000000..13e22b9 --- /dev/null +++ b/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Token validation script — checks for hardcoded colors in source files + * and reports token coverage per product. + * + * Usage: node scripts/validate-tokens.js [product-path] + */ + +const { readFileSync, readdirSync, statSync } = require('fs'); +const { join, resolve } = require('path'); + +const HARD_COLOR_REGEX = /#[0-9A-Fa-f]{3,8}\b|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)/g; +const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; +const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; + +function findFiles(dir, files = []) { + try { + const items = readdirSync(dir); + for (const item of items) { + const fullPath = join(dir, item); + if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + findFiles(fullPath, files); + } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { + files.push(fullPath); + } + } + } catch { + // Directory might not exist or be accessible + } + return files; +} + +function analyzeFile(filePath) { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const issues = []; + + lines.forEach((line, index) => { + // Skip comments + if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) + return; + + const matches = line.match(HARD_COLOR_REGEX); + if (matches) { + // Filter out legitimate uses (like transparency values) + const suspicious = matches.filter(m => { + if (m.startsWith('#') && (m.length === 9 || m.length === 5)) return false; // Skip alpha hex + if (m.includes('0.0') || m.includes('1.0')) return false; // Skip clear/opaque + // Skip CSS-variable token usage (already tokenized) + if ((m.startsWith('hsl(') || m.startsWith('rgb(') || m.startsWith('rgba(')) && m.includes('var(--')) + return false; + return true; + }); + + if (suspicious.length > 0) { + issues.push({ + line: index + 1, + colors: suspicious, + content: line.trim().slice(0, 80), + }); + } + } + }); + + return issues; +} + +function main() { + const targetPath = process.argv[2] || '.'; + const absolutePath = resolve(targetPath); + + console.log(`🔍 Scanning ${absolutePath} for hardcoded colors...\n`); + + const files = findFiles(absolutePath); + let totalIssues = 0; + let filesWithIssues = 0; + + for (const file of files) { + const issues = analyzeFile(file); + if (issues.length > 0) { + filesWithIssues++; + totalIssues += issues.length; + const relativePath = file.replace(absolutePath, '').slice(1); + console.log(`\n📄 ${relativePath}`); + issues.forEach(issue => { + console.log(` Line ${issue.line}: ${issue.colors.join(', ')}`); + console.log(` ${issue.content}`); + }); + } + } + + console.log(`\n${'='.repeat(60)}`); + console.log(`📊 Summary:`); + console.log(` Files scanned: ${files.length}`); + console.log(` Files with hardcoded colors: ${filesWithIssues}`); + console.log(` Total hardcoded colors found: ${totalIssues}`); + + if (totalIssues > 0) { + console.log(`\n⚠️ Consider replacing hardcoded colors with design tokens:`); + console.log(` Web: var(--ml-) from @bytelyst/design-tokens`); + console.log(` iOS: MindLystColors.dark / MindLystColors.light`); + console.log(` KMP: MindLystTokens.Dark. / MindLystTokens.Light.`); + process.exit(1); + } else { + console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`); + process.exit(0); + } +} + +main(); diff --git a/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts b/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts new file mode 100644 index 0000000..93c91f6 --- /dev/null +++ b/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadTokens } from '../index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const generatedDir = resolve(__dirname, '../../generated'); + +describe('loadTokens', () => { + it('returns a valid DesignTokens object', () => { + const tokens = loadTokens(); + expect(tokens).toBeDefined(); + expect(tokens.meta).toBeDefined(); + expect(tokens.meta.name).toBeTruthy(); + expect(tokens.meta.version).toBeTruthy(); + }); + + it('has color palette with expected keys', () => { + const tokens = loadTokens(); + expect(tokens.color).toBeDefined(); + expect(tokens.color.palette).toBeDefined(); + expect(tokens.color.semantic).toBeDefined(); + expect(tokens.color.semantic.dark).toBeDefined(); + expect(tokens.color.brain).toBeDefined(); + }); + + it('has typography with font families', () => { + const tokens = loadTokens(); + expect(tokens.typography.fontFamily).toBeDefined(); + expect(Object.keys(tokens.typography.fontFamily).length).toBeGreaterThan(0); + }); + + it('has spacing values on 4pt grid', () => { + const tokens = loadTokens(); + expect(tokens.spacing).toBeDefined(); + // All spacing values should be multiples of 4 + for (const [, value] of Object.entries(tokens.spacing)) { + expect(value % 4).toBe(0); + } + }); + + it('has radius values', () => { + const tokens = loadTokens(); + expect(tokens.radius).toBeDefined(); + expect(Object.keys(tokens.radius).length).toBeGreaterThan(0); + }); + + it('has motion durations and easings', () => { + const tokens = loadTokens(); + expect(tokens.motion).toBeDefined(); + expect(tokens.motion.duration).toBeDefined(); + expect(tokens.motion.easing).toBeDefined(); + }); + + it('caches after first load', () => { + const t1 = loadTokens(); + const t2 = loadTokens(); + expect(t1).toBe(t2); + }); +}); + +describe('generated files', () => { + it('tokens.css exists and contains --ml- properties', () => { + const cssPath = resolve(generatedDir, 'tokens.css'); + expect(existsSync(cssPath)).toBe(true); + const css = readFileSync(cssPath, 'utf-8'); + expect(css).toContain('--ml-'); + }); + + it('tokens.ts exists and exports token values', () => { + const tsPath = resolve(generatedDir, 'tokens.ts'); + expect(existsSync(tsPath)).toBe(true); + const ts = readFileSync(tsPath, 'utf-8'); + expect(ts).toContain('export'); + }); + + it('MindLystTokens.kt exists and contains object declaration', () => { + const ktPath = resolve(generatedDir, 'MindLystTokens.kt'); + expect(existsSync(ktPath)).toBe(true); + const kt = readFileSync(ktPath, 'utf-8'); + expect(kt).toContain('object MindLystTokens'); + }); + + it('MindLystTheme.swift exists and contains struct', () => { + const swiftPath = resolve(generatedDir, 'MindLystTheme.swift'); + expect(existsSync(swiftPath)).toBe(true); + const swift = readFileSync(swiftPath, 'utf-8'); + expect(swift).toContain('MindLyst'); + }); +}); diff --git a/vendor/bytelyst/design-tokens/src/index.ts b/vendor/bytelyst/design-tokens/src/index.ts new file mode 100644 index 0000000..0ee2faa --- /dev/null +++ b/vendor/bytelyst/design-tokens/src/index.ts @@ -0,0 +1,49 @@ +/** + * Design tokens — programmatic access to the canonical token JSON. + * For generated platform files, see the `generated/` directory. + * For the canonical JSON source, see `tokens/bytelyst.tokens.json`. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface DesignTokens { + meta: { name: string; version: string; updatedAt: string; scale: string }; + color: { + palette: Record>; + semantic: { + dark: Record; + light: Record; + }; + brain: Record; + }; + typography: { + fontFamily: Record; + fontWeight: Record; + fontSize: Record; + lineHeight: Record; + letterSpacing: Record; + }; + spacing: Record; + radius: Record; + elevation: Record; + motion: { + duration: Record; + easing: Record; + }; + breakpoints: Record; + layout: Record; +} + +let _cached: DesignTokens | null = null; + +export function loadTokens(): DesignTokens { + if (_cached) return _cached; + const tokenPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); + const raw = readFileSync(tokenPath, 'utf-8'); + _cached = JSON.parse(raw) as DesignTokens; + return _cached; +} diff --git a/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json b/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json new file mode 100644 index 0000000..007e896 --- /dev/null +++ b/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json @@ -0,0 +1,369 @@ +{ + "meta": { + "name": "ByteLyst Design Tokens", + "version": "1.1.0", + "updatedAt": "2026-03-03", + "scale": "8pt" + }, + "color": { + "palette": { + "neutral": { + "0": "#FFFFFF", + "50": "#F6F8FC", + "100": "#EEF2FA", + "200": "#DCE4F2", + "300": "#BFCBDE", + "400": "#92A1BA", + "500": "#6C7C98", + "600": "#55637A", + "700": "#3B455A", + "800": "#1A2335", + "900": "#0E1320", + "950": "#06070A" + }, + "brand": { + "blue": "#5A8CFF", + "cyan": "#2EE6D6", + "coral": "#FF6E6E", + "gold": "#FFD166", + "mint": "#34D399", + "warning": "#F59E0B", + + "microsoftRed": "#F25022", + "microsoftGreen": "#7FBA00", + "microsoftBlue": "#00A4EF", + "microsoftYellow": "#FFB900", + + "googleBlue": "#4285F4", + "googleGreen": "#34A853", + "googleYellow": "#FBBC05", + "googleRed": "#EA4335" + } + }, + "semantic": { + "dark": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "borderDefault": "rgba(255,255,255,0.12)", + "borderStrong": "rgba(255,255,255,0.22)", + "textPrimary": "#EFF4FF", + "textSecondary": "#A5B1C7", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "focusRing": "rgba(90,140,255,0.45)", + "overlayScrim": "rgba(5,8,18,0.72)" + }, + "light": { + "bgCanvas": "#F6F8FC", + "bgElevated": "#EEF2FA", + "surfaceCard": "#FFFFFF", + "surfaceMuted": "#F3F5FA", + "borderDefault": "rgba(14,19,32,0.12)", + "borderStrong": "rgba(14,19,32,0.24)", + "textPrimary": "#0E1320", + "textSecondary": "#55637A", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#13956A", + "warning": "#B87504", + "danger": "#D24242", + "focusRing": "rgba(90,140,255,0.35)", + "overlayScrim": "rgba(10,13,23,0.5)" + } + }, + "brain": { + "work": { "from": "#5A8CFF", "to": "#2EE6D6" }, + "home": { "from": "#FF6E6E", "to": "#FFD166" }, + "money": { "from": "#34D399", "to": "#2EE6D6" }, + "health": { "from": "#2EE6D6", "to": "#9FE870" }, + "global": { "from": "#7D8FB4", "to": "#A5B1C7" } + }, + "jarvisjr": { + "accentPrimary": "#7C6BFF", + "accentSecondary": "#5AE6C8", + "accentVoice": "#FF6B8A", + "agentCoach": "#5A8CFF", + "agentLingua": "#FFB74D", + "agentSpark": "#E040FB", + "agentMentor": "#34D399", + "agentMirror": "#2EE6D6", + "agentOrator": "#FF9F43" + }, + "peakpulse": { + "activityHike": "#34D399", + "activitySki": "#5A8CFF", + "speedZoneSlow": "#34D399", + "speedZoneFast": "#FFD166", + "speedZoneDanger": "#FF6E6E", + "elevationGain": "#2EE6D6", + "elevationLoss": "#FF9F43", + "personalBest": "#FFD700", + "streakActive": "#34D399", + "streakBroken": "#FF6E6E" + }, + "chronomind": { + "urgencyCritical": "#FF6E6E", + "urgencyImportant": "#FFD166", + "urgencyStandard": "#5A8CFF", + "urgencyGentle": "#34D399", + "urgencyPassive": "#A5B1C7", + "focusMode": "#7C6BFF", + "pomodoroWork": "#34D399", + "pomodoroBreak": "#5A8CFF", + "cascadeWarning": "#FF9F43", + "timerComplete": "#34D399" + }, + "nomgap": { + "stageFed": "#FF9F43", + "stageEarlyFast": "#FECA57", + "stageFasted": "#48DBFB", + "stageKetosis": "#5A8CFF", + "stageDeepAutophagy": "#A66BFF", + "stageExtended": "#FFD700", + "autophagyMeter": "#5AE68C", + "hydrationReminder": "#48DBFB", + "electrolyteAlert": "#FF9F43", + "safetyWarning": "#FF6E6E" + }, + "lysnrai": { + "recordingActive": "#FF6E6E", + "recordingPaused": "#FFD166", + "processing": "#5A8CFF", + "transcribed": "#34D399", + "dictationMode": "#7C6BFF", + "commandMode": "#2EE6D6", + "hotkeyActive": "#FF6B8A" + }, + "flowmonk": { + "bg": "#07111F", + "surface": "#0F1B2D", + "surfaceElevated": "#152338", + "border": "#24344D", + "text": "#EFF4FF", + "textMuted": "#A8B4C8", + "primary": "#5A8CFF", + "accent": "#5AE68C", + "warning": "#F59E0B", + "zonework": "#5A8CFF", + "zonePersonal": "#5AE68C", + "zoneHealth": "#FF6B6B", + "zoneAdmin": "#FECA57", + "zoneLearning": "#A66BFF", + "urgentBadge": "#FF6E6E", + "scheduleEntry": "#5A8CFF", + "overflowWarning": "#F59E0B", + "recommendationInfo": "#5A8CFF", + "recommendationWarning": "#F59E0B", + "recommendationCritical": "#FF6E6E" + }, + "actiontrail": { + "bg": "#07111F", + "surface": "#0F1B2D", + "surfaceElevated": "#152338", + "border": "#24344D", + "text": "#EFF4FF", + "textMuted": "#A8B4C8", + "primary": "#5A8CFF", + "accent": "#5AE68C", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "riskLow": "#5AE68C", + "riskMedium": "#F59E0B", + "riskHigh": "#FF8C42", + "riskCritical": "#FF6E6E", + "statusPending": "#F59E0B", + "statusApplied": "#5AE68C", + "statusRejected": "#FF6E6E", + "statusReverted": "#A66BFF" + }, + "notelett": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "focusRing": "#5A8CFF", + "agentAction": "#A66BFF", + "draftNote": "#FFD166", + "linkedNote": "#2EE6D6", + "taskPending": "#F59E0B", + "taskComplete": "#34D399" + }, + "localmemgpt": { + "bgPrimary": "#0A0A0A", + "bgSecondary": "#141414", + "bgTertiary": "#1E1E1E", + "bgHover": "#252525", + "bgInput": "#1A1A1A", + "border": "#2A2A2A", + "textPrimary": "#F0F0F0", + "textSecondary": "#999999", + "textMuted": "#666666", + "accent": "#6366F1", + "accentHover": "#818CF8", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444" + }, + "localllmlab": { + "bgCanvas": "#06070A", + "bgElevated": "#0E1118", + "surfaceCard": "#121725", + "surfaceMuted": "#1A2335", + "borderSubtle": "#1E293B", + "borderDefault": "#2A3654", + "textPrimary": "#EFF4FF", + "textSecondary": "#A5B1C7", + "textTertiary": "#6C7C98", + "accentPrimary": "#5A8CFF", + "accentSecondary": "#2EE6D6", + "success": "#34D399", + "warning": "#F59E0B", + "danger": "#FF6E6E", + "purple": "#A78BFA" + } + }, + "typography": { + "fontFamily": { + "display": "'Space Grotesk', 'SF Pro Display', sans-serif", + "body": "'DM Sans', 'SF Pro Text', sans-serif", + "mono": "'IBM Plex Mono', 'SF Mono', monospace" + }, + "fontWeight": { + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "fontSize": { + "xs": 12, + "sm": 14, + "md": 16, + "lg": 18, + "xl": 22, + "2xl": 28, + "3xl": 36 + }, + "lineHeight": { + "tight": 1.2, + "normal": 1.45, + "relaxed": 1.65 + }, + "letterSpacing": { + "tight": -0.02, + "normal": 0, + "wide": 0.02 + } + }, + "spacing": { + "0": 0, + "1": 4, + "2": 8, + "3": 12, + "4": 16, + "5": 20, + "6": 24, + "7": 28, + "8": 32, + "10": 40, + "12": 48, + "16": 64 + }, + "radius": { + "xs": 8, + "sm": 12, + "md": 16, + "lg": 20, + "xl": 24, + "pill": 999 + }, + "elevation": { + "none": "0 0 0 rgba(0,0,0,0)", + "sm": "0 4px 12px rgba(0,0,0,0.12)", + "md": "0 12px 28px rgba(0,0,0,0.18)", + "lg": "0 20px 48px rgba(0,0,0,0.24)" + }, + "motion": { + "duration": { + "instant": 70, + "fast": 140, + "base": 220, + "slow": 320 + }, + "easing": { + "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", + "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", + "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" + } + }, + "breakpoints": { + "mobile": 0, + "tablet": 768, + "desktop": 1200, + "wide": 1440 + }, + "layout": { + "maxContentWidth": 1280, + "mobileGutter": 16, + "tabletGutter": 24, + "desktopGutter": 32, + "touchTargetMin": 44 + }, + "zIndex": { + "hidden": -1, + "base": 0, + "dropdown": 100, + "sticky": 200, + "fixed": 300, + "overlay": 400, + "modal": 500, + "popover": 600, + "toast": 700, + "tooltip": 800 + }, + "icon": { + "xs": 12, + "sm": 16, + "md": 20, + "lg": 24, + "xl": 32, + "2xl": 48 + }, + "grid": { + "columns": 12, + "gutter": 24, + "maxWidth": 1200, + "breakpoints": { + "xs": 0, + "sm": 576, + "md": 768, + "lg": 992, + "xl": 1200, + "xxl": 1400 + } + }, + "opacity": { + "0": 0, + "10": 0.1, + "20": 0.2, + "30": 0.3, + "40": 0.4, + "50": 0.5, + "60": 0.6, + "70": 0.7, + "80": 0.8, + "90": 0.9, + "100": 1 + } +} diff --git a/vendor/bytelyst/design-tokens/tsconfig.json b/vendor/bytelyst/design-tokens/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/design-tokens/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/diagnostics-client/package.json b/vendor/bytelyst/diagnostics-client/package.json new file mode 100644 index 0000000..079dbd5 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bytelyst/diagnostics-client", + "version": "0.1.5", + "description": "TypeScript client for remote diagnostics and debug tracing", + "type": "module", + "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 --pool forks" + }, + "dependencies": { + "@bytelyst/api-client": "workspace:*" + }, + "peerDependencies": { + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts b/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts new file mode 100644 index 0000000..4b6364c --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + DiagnosticsClient, + BreadcrumbTrail, + NetworkInterceptor, +} from '../index.js'; + +describe('DiagnosticsClient', () => { + const mockConfig = { + productId: 'test-app', + anonymousInstallId: 'install_123', + platform: 'web', + channel: 'web_app', + osFamily: 'macos', + appVersion: '1.0.0', + buildNumber: '100', + releaseChannel: 'beta', + serverUrl: 'https://api.test.com', + pollIntervalMs: 100, // Fast polling for tests + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + }; + + let fetchMock: ReturnType; + + beforeEach(() => { + DiagnosticsClient.reset(); + vi.clearAllMocks(); + + // Mock fetch to return empty session (no active debug session) + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + json: async () => null, // No active session + }); + globalThis.fetch = fetchMock; + }); + + afterEach(() => { + DiagnosticsClient.reset(); + vi.restoreAllMocks(); + }); + + describe('singleton', () => { + it('should create instance with getInstance', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + expect(client).toBeDefined(); + expect(DiagnosticsClient.isInitialized()).toBe(true); + }); + + it('should return same instance on subsequent calls', () => { + const client1 = DiagnosticsClient.getInstance(mockConfig); + const client2 = DiagnosticsClient.getInstance(); + expect(client1).toBe(client2); + }); + + it('should throw if getInstance called without config first', () => { + expect(() => DiagnosticsClient.getInstance()).toThrow( + 'must be initialized with config first' + ); + }); + + it('should reset instance', () => { + DiagnosticsClient.getInstance(mockConfig); + expect(DiagnosticsClient.isInitialized()).toBe(true); + DiagnosticsClient.reset(); + expect(DiagnosticsClient.isInitialized()).toBe(false); + }); + }); + + describe('lifecycle', () => { + it('should start and stop', async () => { + const client = DiagnosticsClient.getInstance(mockConfig); + await client.start(); + // After start, state should be polling (no active session from mock) + expect(client.getState().type).toBe('polling'); + client.stop(); + expect(client.getState().type).toBe('idle'); + }); + + it('should warn if started twice', async () => { + const client = DiagnosticsClient.getInstance(mockConfig); + await client.start(); + // First clear any calls from the first start + mockConfig.logger.warn.mockClear(); + // Second start should warn + await client.start(); + expect(mockConfig.logger.warn).toHaveBeenCalledWith( + '[diagnostics] Already started' + ); + }); + }); + + describe('session state', () => { + it('should report no active session initially', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + expect(client.isSessionActive()).toBe(false); + expect(client.getCurrentSession()).toBeNull(); + }); + }); + + describe('logging', () => { + it('should record log entries', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + client.log('info', 'Test message', { foo: 'bar' }); + // Logs are buffered, no immediate assertion + expect(client.getBreadcrumbs().length).toBeGreaterThan(0); + }); + + it('should add breadcrumb on log', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + client.log('warn', 'Warning message'); + const crumbs = client.getBreadcrumbs(); + expect(crumbs.some(c => c.message.includes('Warning'))).toBe(true); + }); + }); + + describe('tracing', () => { + it('should trace successful operation', async () => { + const client = DiagnosticsClient.getInstance(mockConfig); + const result = await client.trace('test-op', () => 'success'); + expect(result).toBe('success'); + }); + + it('should trace async operation', async () => { + const client = DiagnosticsClient.getInstance(mockConfig); + const result = await client.trace('async-op', async () => { + await new Promise(r => setTimeout(r, 10)); + return 42; + }); + expect(result).toBe(42); + }); + + it('should propagate errors', async () => { + const client = DiagnosticsClient.getInstance(mockConfig); + await expect( + client.trace('failing-op', () => { + throw new Error('test error'); + }) + ).rejects.toThrow('test error'); + }); + }); + + describe('breadcrumbs', () => { + it('should add breadcrumbs', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + client.breadcrumb('navigation', 'Page loaded', { path: '/' }); + const crumbs = client.getBreadcrumbs(); + expect(crumbs.length).toBe(1); + expect(crumbs[0].category).toBe('navigation'); + expect(crumbs[0].message).toBe('Page loaded'); + }); + + it('should include data in breadcrumbs', () => { + const client = DiagnosticsClient.getInstance(mockConfig); + client.breadcrumb('user', 'Clicked', { button: 'submit' }); + const crumbs = client.getBreadcrumbs(); + expect(crumbs[0].data).toEqual({ button: 'submit' }); + }); + }); +}); + +describe('BreadcrumbTrail', () => { + it('should add breadcrumbs', () => { + const trail = new BreadcrumbTrail(); + trail.add('test', 'message'); + expect(trail.size()).toBe(1); + }); + + it('should evict oldest when over limit', () => { + const trail = new BreadcrumbTrail({ maxSize: 3 }); + trail.add('a', '1'); + trail.add('b', '2'); + trail.add('c', '3'); + trail.add('d', '4'); // Should evict 'a' + expect(trail.size()).toBe(3); + const all = trail.getAll(); + expect(all[0].category).toBe('b'); + }); + + it('should get last N breadcrumbs', () => { + const trail = new BreadcrumbTrail(); + trail.add('a', '1'); + trail.add('b', '2'); + trail.add('c', '3'); + const last2 = trail.getLast(2); + expect(last2.length).toBe(2); + expect(last2[0].category).toBe('b'); + }); + + it('should get most recent', () => { + const trail = new BreadcrumbTrail(); + trail.add('a', '1'); + trail.add('b', '2'); + const recent = trail.getMostRecent(); + expect(recent?.category).toBe('b'); + }); + + it('should clear all', () => { + const trail = new BreadcrumbTrail(); + trail.add('a', '1'); + trail.clear(); + expect(trail.size()).toBe(0); + }); +}); + +describe('NetworkInterceptor', () => { + it('should start and stop', () => { + const onRequest = vi.fn(); + const interceptor = new NetworkInterceptor(onRequest); + interceptor.start(); + expect(interceptor.isRunning()).toBe(true); + interceptor.stop(); + expect(interceptor.isRunning()).toBe(false); + }); + + it('should not capture when stopped', () => { + const onRequest = vi.fn(); + const interceptor = new NetworkInterceptor(onRequest); + expect(interceptor.isRunning()).toBe(false); + }); +}); diff --git a/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts b/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts new file mode 100644 index 0000000..4898498 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts @@ -0,0 +1,78 @@ +/** + * Breadcrumb trail — ring buffer for timeline navigation + * + * @module breadcrumbs + */ + +import type { Breadcrumb } from './types.js'; + +export interface BreadcrumbTrailOptions { + /** Maximum number of breadcrumbs to keep (default: 100) */ + maxSize?: number; +} + +/** + * Ring buffer for breadcrumbs with fixed max size + */ +export class BreadcrumbTrail { + private breadcrumbs: Breadcrumb[] = []; + private maxSize: number; + + constructor(options: BreadcrumbTrailOptions = {}) { + this.maxSize = options.maxSize ?? 100; + } + + /** + * Add a breadcrumb to the trail + */ + add(category: string, message: string, data?: Record): void { + const breadcrumb: Breadcrumb = { + timestamp: new Date().toISOString(), + category, + message, + data, + }; + + this.breadcrumbs.push(breadcrumb); + + // Evict oldest if over limit + if (this.breadcrumbs.length > this.maxSize) { + this.breadcrumbs.shift(); + } + } + + /** + * Get all breadcrumbs (oldest first) + */ + getAll(): Breadcrumb[] { + return [...this.breadcrumbs]; + } + + /** + * Get last N breadcrumbs + */ + getLast(n: number): Breadcrumb[] { + return this.breadcrumbs.slice(-n); + } + + /** + * Get most recent breadcrumb + */ + getMostRecent(): Breadcrumb | null { + return this.breadcrumbs[this.breadcrumbs.length - 1] ?? null; + } + + /** + * Clear all breadcrumbs + */ + clear(): void { + this.breadcrumbs = []; + } + + /** + * Get current size + */ + size(): number { + return this.breadcrumbs.length; + } +} diff --git a/vendor/bytelyst/diagnostics-client/src/client.ts b/vendor/bytelyst/diagnostics-client/src/client.ts new file mode 100644 index 0000000..0118e8b --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/client.ts @@ -0,0 +1,573 @@ +/** + * Main DiagnosticsClient — singleton for remote diagnostics collection + * + * @module client + */ + +import type { + DiagnosticsConfig, + DiagnosticsSession, + ClientState, + LogLevel, + TraceSpan, + LogEntry, + Breadcrumb, + NetworkRequest, + DeviceState, +} from './types.js'; +import { BreadcrumbTrail } from './breadcrumbs.js'; +import { NetworkInterceptor } from './network.js'; +import { collectDeviceState } from './device.js'; + +// DOM type declarations for ESLint +type ErrorEvent = { + message: string; + filename: string; + lineno: number; + colno: number; + error?: { stack?: string }; +}; + +export interface DiagnosticsClientOptions extends DiagnosticsConfig { + /** Custom logger */ + logger?: { + debug: (msg: string, meta?: Record) => void; + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + error: (msg: string, meta?: Record) => void; + }; +} + +/** + * Diagnostics client for remote debug session collection + */ +export class DiagnosticsClient { + private static instance: DiagnosticsClient | null = null; + + private config: DiagnosticsClientOptions & { + pollIntervalMs: number; + maxBreadcrumbs: number; + captureConsole: boolean; + captureErrors: boolean; + captureNetwork: boolean; + networkExcludePatterns: RegExp[]; + logger: NonNullable; + }; + private state: ClientState = { type: 'idle' }; + private breadcrumbs: BreadcrumbTrail; + private networkInterceptor: NetworkInterceptor | null = null; + private pollTimer: ReturnType | null = null; + private logBuffer: LogEntry[] = []; + private traceBuffer: TraceSpan[] = []; + private networkBuffer: NetworkRequest[] = []; + private flushTimer: ReturnType | null = null; + private lastEtag: string | null = null; + + private constructor(config: DiagnosticsClientOptions) { + this.config = { + ...config, + pollIntervalMs: config.pollIntervalMs ?? 5000, + maxBreadcrumbs: config.maxBreadcrumbs ?? 100, + captureConsole: config.captureConsole ?? true, + captureErrors: config.captureErrors ?? true, + captureNetwork: config.captureNetwork ?? true, + networkExcludePatterns: config.networkExcludePatterns ?? [], + logger: config.logger ?? { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }; + + this.breadcrumbs = new BreadcrumbTrail({ + maxSize: this.config.maxBreadcrumbs, + }); + } + + /** + * Get singleton instance + */ + static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient { + if (!DiagnosticsClient.instance) { + if (!config) { + throw new Error('DiagnosticsClient must be initialized with config first'); + } + DiagnosticsClient.instance = new DiagnosticsClient(config); + } + return DiagnosticsClient.instance; + } + + /** + * Check if client is initialized + */ + static isInitialized(): boolean { + return DiagnosticsClient.instance !== null; + } + + /** + * Reset singleton (for testing) + */ + static reset(): void { + DiagnosticsClient.instance?.stop(); + DiagnosticsClient.instance = null; + } + + /** + * Start polling for active debug sessions + */ + async start(): Promise { + if (this.state.type === 'polling' || this.state.type === 'active') { + this.config.logger.warn('[diagnostics] Already started'); + return; + } + + this.state = { type: 'polling', session: null }; + this.config.logger.info('[diagnostics] Starting diagnostics client'); + + // Initial poll + await this.pollForSession(); + + // Start polling timer + this.pollTimer = setInterval(() => { + this.pollForSession().catch(err => { + this.config.logger.error('[diagnostics] Poll error', { error: err.message }); + }); + }, this.config.pollIntervalMs); + + // Start auto-flush timer (every 30 seconds) + this.flushTimer = setInterval(() => { + this.flush().catch(err => { + this.config.logger.error('[diagnostics] Flush error', { error: err.message }); + }); + }, 30000); + + // Setup auto-capture if configured + if (this.config.captureNetwork) { + this.setupNetworkCapture(); + } + + if (this.config.captureConsole) { + this.setupConsoleCapture(); + } + + if (this.config.captureErrors) { + this.setupErrorCapture(); + } + + this.breadcrumbs.add('diagnostics', 'Client started'); + } + + /** + * Stop polling and cleanup + */ + stop(): void { + this.config.logger.info('[diagnostics] Stopping diagnostics client'); + + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + if (this.flushTimer) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + + this.networkInterceptor?.stop(); + this.networkInterceptor = null; + + // Final flush + this.flush().catch(() => {}); + + this.state = { type: 'idle' }; + this.breadcrumbs.add('diagnostics', 'Client stopped'); + } + + /** + * Check if a debug session is currently active + */ + isSessionActive(): boolean { + return this.state.type === 'active'; + } + + /** + * Get current session if active + */ + getCurrentSession(): DiagnosticsSession | null { + return this.state.type === 'active' || this.state.type === 'polling' + ? (this.state as { session: DiagnosticsSession | null }).session + : null; + } + + /** + * Get current client state + */ + getState(): ClientState { + return this.state; + } + + /** + * Record a log entry + */ + log(level: LogLevel, message: string, context: Record = {}): void { + const entry: LogEntry = { + level, + message, + timestamp: new Date().toISOString(), + module: (context.module as string) ?? 'unknown', + context, + correlationId: context.correlationId as string, + }; + + this.logBuffer.push(entry); + this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level }); + + // Auto-flush on fatal + if (level === 'fatal') { + this.flush().catch(() => {}); + } + } + + /** + * Record a trace span (auto-instrumented) + */ + async trace(name: string, operation: () => Promise): Promise; + async trace(name: string, operation: () => T): Promise; + async trace(name: string, operation: () => T | Promise): Promise { + const span: TraceSpan = { + spanId: this.generateId(), + name, + kind: 'internal', + startTime: new Date().toISOString(), + attributes: {}, + status: 'unset', + }; + + this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId }); + + try { + const result = await operation(); + span.endTime = new Date().toISOString(); + span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); + span.status = 'ok'; + this.traceBuffer.push(span); + this.breadcrumbs.add('trace', `Completed: ${name}`, { + spanId: span.spanId, + durationMs: span.durationMs, + }); + return result; + } catch (error) { + span.endTime = new Date().toISOString(); + span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); + span.status = 'error'; + span.statusMessage = error instanceof Error ? error.message : String(error); + this.traceBuffer.push(span); + this.breadcrumbs.add('trace', `Failed: ${name}`, { + spanId: span.spanId, + error: span.statusMessage, + }); + throw error; + } + } + + /** + * Add a manual breadcrumb + */ + breadcrumb(category: string, message: string, data?: Record): void { + this.breadcrumbs.add(category, message, data); + } + + /** + * Get all breadcrumbs + */ + getBreadcrumbs(): Breadcrumb[] { + return this.breadcrumbs.getAll(); + } + + /** + * Collect and return device state + */ + collectDeviceState(): DeviceState { + return collectDeviceState(); + } + + /** + * Poll server for active session config + */ + private async pollForSession(): Promise { + try { + const url = new URL('/api/diagnostics/config', this.config.serverUrl); + url.searchParams.set('productId', this.config.productId); + url.searchParams.set('installId', this.config.anonymousInstallId); + + const headers: Record = { + Accept: 'application/json', + }; + + if (this.lastEtag) { + headers['If-None-Match'] = this.lastEtag; + } + + const token = await this.getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url.toString(), { headers }); + + if (response.status === 304) { + // No change + return; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + // Store ETag for caching + const etag = response.headers.get('ETag'); + if (etag) { + this.lastEtag = etag; + } + + const session: DiagnosticsSession | null = await response.json(); + + // Update state + if (session && session.status === 'active') { + if (this.state.type !== 'active') { + this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id }); + this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id }); + } + this.state = { type: 'active', session }; + } else { + if (this.state.type === 'active') { + this.config.logger.info('[diagnostics] Session ended'); + this.breadcrumbs.add('diagnostics', 'Session ended'); + } + this.state = { type: 'polling', session: null }; + } + } catch (error) { + this.config.logger.error('[diagnostics] Failed to poll for session', { + error: error instanceof Error ? error.message : String(error), + }); + this.state = { + type: 'error', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Flush buffered data to server + */ + private async flush(): Promise { + const session = this.getCurrentSession(); + if (!session) { + // No active session, clear buffers + this.logBuffer = []; + this.traceBuffer = []; + this.networkBuffer = []; + return; + } + + const sessionId = session.id; + + const logs = this.logBuffer.splice(0, 50); // Server max: 50 + const traces = this.traceBuffer.splice(0, 50); // Server max: 50 + const network = this.networkBuffer.splice(0, 50); + const crumbs = this.breadcrumbs.getAll(); + this.breadcrumbs.clear(); + + // Encode breadcrumbs + network captures as log entries so we can ingest + // without requiring additional server-side schemas/endpoints. + const synthesizedLogs = [] as LogEntry[]; + + for (const c of crumbs) { + synthesizedLogs.push({ + level: 'info', + message: `[breadcrumb] ${c.category}: ${c.message}`, + timestamp: c.timestamp, + module: 'diagnostics.breadcrumb', + context: c.data ?? {}, + }); + } + + for (const n of network) { + synthesizedLogs.push({ + level: n.error ? 'error' : 'info', + message: `[network] ${n.method} ${n.url} ${n.status ?? ''}`.trim(), + timestamp: n.startTime, + module: 'diagnostics.network', + context: { + requestHeaders: n.requestHeaders, + requestBody: n.requestBody, + status: n.status, + responseHeaders: n.responseHeaders, + responseBody: n.responseBody, + startTime: n.startTime, + endTime: n.endTime, + durationMs: n.durationMs, + error: n.error, + }, + }); + } + + const allLogs = [...logs, ...synthesizedLogs]; + + if (allLogs.length === 0 && traces.length === 0) { + return; + } + + const token = await this.getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + try { + if (allLogs.length > 0) { + const url = new URL( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs`, + this.config.serverUrl + ); + const response = await fetch(url.toString(), { + method: 'POST', + headers, + body: JSON.stringify({ sessionId, logs: allLogs }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } + + if (traces.length > 0) { + const url = new URL( + `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces`, + this.config.serverUrl + ); + const response = await fetch(url.toString(), { + method: 'POST', + headers, + body: JSON.stringify({ sessionId, traces }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } + + this.config.logger.debug('[diagnostics] Flushed batch', { + logs: allLogs.length, + traces: traces.length, + }); + } catch (error) { + this.config.logger.error('[diagnostics] Failed to flush batch', { + error: error instanceof Error ? error.message : String(error), + }); + + // Put items back in buffers for retry + if (logs.length > 0) this.logBuffer.unshift(...logs); + if (traces.length > 0) this.traceBuffer.unshift(...traces); + if (network.length > 0) this.networkBuffer.unshift(...network); + + // Breadcrumbs were converted; keep a small breadcrumb trail hint for later flush. + for (const c of crumbs.slice(-10)) { + this.breadcrumbs.add(c.category, c.message, c.data); + } + } + } + + /** + * Setup network capture + */ + private setupNetworkCapture(): void { + this.networkInterceptor = new NetworkInterceptor( + request => { + this.networkBuffer.push(request); + }, + { + excludePatterns: this.config.networkExcludePatterns, + } + ); + this.networkInterceptor.start(); + this.breadcrumbs.add('diagnostics', 'Network capture enabled'); + } + + /** + * Setup console capture + */ + /* eslint-disable no-console -- This method intentionally wraps console APIs to capture diagnostics, then forwards to the originals. */ + private setupConsoleCapture(): void { + const originalConsole = { + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + const capture = (level: LogLevel, args: unknown[]) => { + const message = args + .map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))) + .join(' '); + this.log(level, message, { module: 'console', source: 'captured' }); + }; + + console.log = (...args: unknown[]) => { + capture('debug', args); + originalConsole.log(...args); + }; + console.info = (...args: unknown[]) => { + capture('info', args); + originalConsole.info(...args); + }; + console.warn = (...args: unknown[]) => { + capture('warn', args); + originalConsole.warn(...args); + }; + console.error = (...args: unknown[]) => { + capture('error', args); + originalConsole.error(...args); + }; + + this.breadcrumbs.add('diagnostics', 'Console capture enabled'); + } + /* eslint-enable no-console */ + + /** + * Setup error capture + */ + private setupErrorCapture(): void { + if (typeof window === 'undefined') return; + + const handler = (event: ErrorEvent) => { + this.log('error', event.message, { + module: 'window.onerror', + source: 'captured', + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error?.stack, + }); + this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`); + }; + + window.addEventListener('error', handler); + this.breadcrumbs.add('diagnostics', 'Error capture enabled'); + } + + /** + * Get auth token + */ + private async getAuthToken(): Promise { + if (!this.config.getAuthToken) return null; + try { + const token = await this.config.getAuthToken(); + return token; + } catch { + return null; + } + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; + } +} diff --git a/vendor/bytelyst/diagnostics-client/src/device.ts b/vendor/bytelyst/diagnostics-client/src/device.ts new file mode 100644 index 0000000..efe43b7 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/device.ts @@ -0,0 +1,86 @@ +/** + * Device state collector — memory, battery, storage, network + * + * @module device + */ + +import type { DeviceState } from './types.js'; + +// DOM type declarations for ESLint +type Navigator = { + onLine: boolean; + connection?: { effectiveType?: string }; + getBattery?: () => Promise<{ charging: boolean; level: number }>; + storage?: { estimate(): Promise<{ usage?: number }> }; +}; +declare const navigator: Navigator; +declare const performance: { memory?: { usedJSHeapSize: number } }; +interface Window { + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; +} +declare const window: Window; + +/** + * Collect current device state + * Best-effort: some APIs may not be available in all environments + */ +export function collectDeviceState(): DeviceState { + const state: DeviceState = { + isOnline: navigator.onLine ?? true, + }; + + // Network type (experimental API) + const connection = (navigator as { connection?: { effectiveType?: string } }).connection; + if (connection) { + state.networkType = connection.effectiveType ?? 'unknown'; + } + + // Battery API (experimental, not widely supported) + // Note: Battery API is deprecated but still useful for diagnostics + const battery = ( + navigator as { getBattery?: () => Promise<{ charging: boolean; level: number }> } + ).getBattery; + if (battery) { + // We'll return a promise, but sync API can't wait + // Store last known value if available + } + + // Memory (Chrome-only experimental) + const memory = (performance as { memory?: { usedJSHeapSize: number } }).memory; + if (memory) { + state.memoryMB = Math.round(memory.usedJSHeapSize / 1024 / 1024); + } + + // Storage (async, but we'll fire-and-forget) + if (navigator.storage && navigator.storage.estimate) { + navigator.storage + .estimate() + .then(estimate => { + if (estimate.usage !== undefined) { + state.storageMB = Math.round(estimate.usage / 1024 / 1024); + } + }) + .catch(() => { + // Ignore errors + }); + } + + return state; +} + +/** + * Subscribe to online/offline events + */ +export function subscribeToConnectivity(callback: (isOnline: boolean) => void): () => void { + const handleOnline = () => callback(true); + const handleOffline = () => callback(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; +} diff --git a/vendor/bytelyst/diagnostics-client/src/index.ts b/vendor/bytelyst/diagnostics-client/src/index.ts new file mode 100644 index 0000000..426000b --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/index.ts @@ -0,0 +1,61 @@ +/** + * @bytelyst/diagnostics-client + * + * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. + * Provides polling, logging, tracing, network capture, and breadcrumbs. + * + * @example + * ```typescript + * import { DiagnosticsClient } from '@bytelyst/diagnostics-client'; + * + * const client = DiagnosticsClient.getInstance({ + * productId: 'myapp', + * anonymousInstallId: 'install_123', + * platform: 'web', + * channel: 'web_app', + * osFamily: 'macos', + * appVersion: '1.0.0', + * buildNumber: '100', + * releaseChannel: 'stable', + * serverUrl: 'https://api.bytelyst.com', + * }); + * + * await client.start(); + * + * // Auto-instrumented trace + * const result = await client.trace('fetchUser', async () => { + * return await fetch('/api/user').then(r => r.json()); + * }); + * + * // Manual breadcrumb + * client.breadcrumb('user', 'Clicked submit button', { formId: 'signup' }); + * + * // Manual log + * client.log('info', 'User signed up', { userId: '123' }); + * ``` + */ + +export { DiagnosticsClient, type DiagnosticsClientOptions } from './client.js'; + +export { createWebDiagnostics, type WebDiagnosticsConfig } from './web.js'; + +export { BreadcrumbTrail, type BreadcrumbTrailOptions } from './breadcrumbs.js'; + +export { NetworkInterceptor, type NetworkInterceptorOptions } from './network.js'; + +export { collectDeviceState, subscribeToConnectivity } from './device.js'; + +export type { + LogLevel, + SessionStatus, + CollectionLevel, + DiagnosticsSession, + TraceSpan, + LogEntry, + Breadcrumb, + NetworkRequest, + DeviceState, + DiagnosticsConfig, + ClientState, + IngestBatch, +} from './types.js'; diff --git a/vendor/bytelyst/diagnostics-client/src/network.ts b/vendor/bytelyst/diagnostics-client/src/network.ts new file mode 100644 index 0000000..dfc3956 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/network.ts @@ -0,0 +1,214 @@ +/** + * Network interceptor — capture HTTP requests/responses + * + * @module network + */ + +import type { NetworkRequest } from './types.js'; + +// DOM type declarations for ESLint +type RequestInfo = string | Request | URL; +type HeadersInit = Headers | Record | string[][]; + +export interface NetworkInterceptorOptions { + /** URL patterns to include (default: all) */ + includePatterns?: RegExp[]; + /** URL patterns to exclude */ + excludePatterns?: RegExp[]; + /** Max request body size to capture (default: 100KB) */ + maxBodySize?: number; + /** Whether to capture request headers (default: true) */ + captureRequestHeaders?: boolean; + /** Whether to capture response headers (default: true) */ + captureResponseHeaders?: boolean; + /** Sanitize header values matching these patterns */ + sensitiveHeaderPatterns?: RegExp[]; +} + +/** + * Interceptor for capturing network requests + */ +export class NetworkInterceptor { + private options: Required; + private originalFetch: typeof fetch; + private isActive = false; + private pendingRequests = new Map(); + private onRequest: (request: NetworkRequest) => void; + + constructor( + onRequest: (request: NetworkRequest) => void, + options: NetworkInterceptorOptions = {} + ) { + this.onRequest = onRequest; + this.options = { + includePatterns: options.includePatterns ?? [], + excludePatterns: options.excludePatterns ?? [], + maxBodySize: options.maxBodySize ?? 100 * 1024, + captureRequestHeaders: options.captureRequestHeaders ?? true, + captureResponseHeaders: options.captureResponseHeaders ?? true, + sensitiveHeaderPatterns: options.sensitiveHeaderPatterns ?? [ + /authorization/i, + /cookie/i, + /token/i, + /api-key/i, + ], + }; + this.originalFetch = globalThis.fetch.bind(globalThis); + } + + /** + * Start intercepting fetch calls + */ + start(): void { + if (this.isActive) return; + this.isActive = true; + + globalThis.fetch = this.interceptedFetch.bind(this); + } + + /** + * Stop intercepting fetch calls + */ + stop(): void { + if (!this.isActive) return; + this.isActive = false; + + globalThis.fetch = this.originalFetch; + } + + /** + * Check if URL should be captured + */ + private shouldCapture(url: string): boolean { + // Check excludes first + for (const pattern of this.options.excludePatterns) { + if (pattern.test(url)) return false; + } + + // If includes specified, must match one + if (this.options.includePatterns.length > 0) { + for (const pattern of this.options.includePatterns) { + if (pattern.test(url)) return true; + } + return false; + } + + return true; + } + + /** + * Sanitize headers + */ + private sanitizeHeaders( + headers: HeadersInit | undefined + ): Record { + const sanitized: Record = {}; + const headerEntries = headers instanceof Headers + ? Array.from(headers.entries()) + : typeof headers === 'object' && headers !== null + ? Object.entries(headers) + : []; + + for (const [key, value] of headerEntries) { + const isSensitive = this.options.sensitiveHeaderPatterns.some(p => + p.test(key) + ); + sanitized[key] = isSensitive ? '[REDACTED]' : value; + } + + return sanitized; + } + + /** + * Generate request ID + */ + private generateId(): string { + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; + } + + /** + * Intercepted fetch implementation + */ + private async interceptedFetch( + input: RequestInfo | URL, + init?: RequestInit + ): Promise { + const url = input.toString(); + const shouldCapture = this.shouldCapture(url); + + const requestId = this.generateId(); + const startTime = new Date().toISOString(); + + // Create request record if capturing + if (shouldCapture) { + const request: NetworkRequest = { + id: requestId, + url: url.slice(0, 2048), // Limit URL length + method: (init?.method ?? 'GET').toUpperCase(), + requestHeaders: this.options.captureRequestHeaders + ? this.sanitizeHeaders(init?.headers) + : {}, + startTime, + }; + + // Capture request body if present and not too large + if (init?.body && typeof init.body === 'string') { + if (init.body.length <= this.options.maxBodySize) { + request.requestBody = init.body.slice(0, this.options.maxBodySize); + } + } + + this.pendingRequests.set(requestId, request); + } + + try { + const response = await this.originalFetch(input, init); + + // Update with response info if capturing + if (shouldCapture) { + const request = this.pendingRequests.get(requestId); + if (request) { + request.status = response.status; + request.endTime = new Date().toISOString(); + request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); + + if (this.options.captureResponseHeaders) { + request.responseHeaders = this.sanitizeHeaders( + Object.fromEntries(response.headers.entries()) + ); + } + + // Don't capture response body (too large/complex) + // Just record that we received a response + + this.pendingRequests.delete(requestId); + this.onRequest(request); + } + } + + return response; + } catch (error) { + // Record error if capturing + if (shouldCapture) { + const request = this.pendingRequests.get(requestId); + if (request) { + request.endTime = new Date().toISOString(); + request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); + request.error = error instanceof Error ? error.message : String(error); + + this.pendingRequests.delete(requestId); + this.onRequest(request); + } + } + + throw error; + } + } + + /** + * Check if interceptor is active + */ + isRunning(): boolean { + return this.isActive; + } +} diff --git a/vendor/bytelyst/diagnostics-client/src/types.ts b/vendor/bytelyst/diagnostics-client/src/types.ts new file mode 100644 index 0000000..c7d3c47 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/types.ts @@ -0,0 +1,233 @@ +/** + * Core types for @bytelyst/diagnostics-client + * + * @module types + */ + +/** + * Log severity levels (matches syslog/OpenTelemetry) + */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +/** + * Session status from the server + */ +export type SessionStatus = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled'; + +/** + * Collection level determines verbosity of captured data + */ +export type CollectionLevel = 'standard' | 'debug' | 'trace'; + +/** + * Diagnostic session configuration from server + */ +export interface DiagnosticsSession { + /** Session ID */ + id: string; + /** Product identifier */ + productId: string; + /** Current session status */ + status: SessionStatus; + /** Collection verbosity level */ + collectionLevel: CollectionLevel; + /** Whether to capture logs */ + captureLogs: boolean; + /** Whether to capture network traces */ + captureNetwork: boolean; + /** Whether to capture screenshots */ + captureScreenshots: boolean; + /** Auto-capture screenshot on error */ + screenshotOnError: boolean; + /** Maximum session duration in minutes */ + maxDurationMinutes: number; + /** Session creation time */ + createdAt: string; + /** Session expiry time */ + expiresAt: string; +} + +/** + * OpenTelemetry-compatible trace span + */ +export interface TraceSpan { + /** Unique span ID */ + spanId: string; + /** Parent span ID (null for root) */ + parentId?: string; + /** Operation name */ + name: string; + /** Span kind */ + kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer'; + /** Start time (ISO 8601) */ + startTime: string; + /** End time (ISO 8601) */ + endTime?: string; + /** Duration in milliseconds */ + durationMs?: number; + /** Custom attributes */ + attributes: Record; + /** Status */ + status: 'ok' | 'error' | 'unset'; + /** Error message if status=error */ + statusMessage?: string; + /** Nested events within this span */ + events?: Array<{ + name: string; + timestamp: string; + attributes?: Record; + }>; +} + +/** + * Structured log entry + */ +export interface LogEntry { + /** Log level */ + level: LogLevel; + /** Log message (PII redacted server-side) */ + message: string; + /** Timestamp (ISO 8601) */ + timestamp: string; + /** Module/component name */ + module: string; + /** Source file path */ + file?: string; + /** Line number */ + line?: number; + /** Function name */ + function?: string; + /** Additional context */ + context: Record; + /** Correlation ID for related operations */ + correlationId?: string; +} + +/** + * Breadcrumb for timeline navigation + */ +export interface Breadcrumb { + /** Timestamp */ + timestamp: string; + /** Category (e.g., 'navigation', 'user', 'error') */ + category: string; + /** Message */ + message: string; + /** Associated data */ + data?: Record; +} + +/** + * Network request/response capture + */ +export interface NetworkRequest { + /** Unique request ID */ + id: string; + /** URL */ + url: string; + /** HTTP method */ + method: string; + /** Request headers (sanitized) */ + requestHeaders: Record; + /** Request body (if captured) */ + requestBody?: string; + /** Response status */ + status?: number; + /** Response headers */ + responseHeaders?: Record; + /** Response body (if captured) */ + responseBody?: string; + /** Start timestamp */ + startTime: string; + /** End timestamp */ + endTime?: string; + /** Duration in milliseconds */ + durationMs?: number; + /** Error if request failed */ + error?: string; +} + +/** + * Device state snapshot + */ +export interface DeviceState { + /** Memory usage in MB */ + memoryMB?: number; + /** Battery level (0-1) */ + batteryLevel?: number; + /** Is battery charging */ + isCharging?: boolean; + /** Available storage in MB */ + storageMB?: number; + /** Network type (wifi, cellular, offline) */ + networkType?: string; + /** Is device online */ + isOnline: boolean; + /** Thermal state (nominal, fair, serious, critical) */ + thermalState?: 'nominal' | 'fair' | 'serious' | 'critical'; +} + +/** + * Client configuration options + */ +export interface DiagnosticsConfig { + /** Product ID */ + productId: string; + /** User ID (if authenticated) */ + userId?: string; + /** Anonymous install ID */ + anonymousInstallId: string; + /** Platform name */ + platform: string; + /** Platform channel */ + channel: string; + /** OS family */ + osFamily: string; + /** App version */ + appVersion: string; + /** Build number */ + buildNumber: string; + /** Release channel */ + releaseChannel: string; + /** Server base URL */ + serverUrl: string; + /** Auth token provider */ + getAuthToken?: () => string | Promise; + /** Polling interval in ms (default: 5000) */ + pollIntervalMs?: number; + /** Max breadcrumbs to keep (default: 100) */ + maxBreadcrumbs?: number; + /** Auto-capture console logs */ + captureConsole?: boolean; + /** Auto-capture uncaught errors */ + captureErrors?: boolean; + /** Auto-capture network requests */ + captureNetwork?: boolean; + /** URL patterns to exclude from network capture */ + networkExcludePatterns?: RegExp[]; +} + +/** + * Client state + */ +export type ClientState = + | { type: 'idle' } + | { type: 'polling'; session: DiagnosticsSession | null } + | { type: 'active'; session: DiagnosticsSession } + | { type: 'error'; error: Error }; + +/** + * Ingest batch for sending to server + */ +export interface IngestBatch { + /** Session ID */ + sessionId: string; + /** Traces to ingest */ + traces?: TraceSpan[]; + /** Logs to ingest */ + logs?: LogEntry[]; + /** Breadcrumbs to ingest */ + breadcrumbs?: Breadcrumb[]; + /** Network requests to ingest */ + network?: NetworkRequest[]; +} diff --git a/vendor/bytelyst/diagnostics-client/src/web.ts b/vendor/bytelyst/diagnostics-client/src/web.ts new file mode 100644 index 0000000..25aac86 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/src/web.ts @@ -0,0 +1,111 @@ +/** + * Convenience factory for web dashboard diagnostics. + * + * Eliminates ~40 lines of boilerplate per web app by wrapping + * DiagnosticsClient.getInstance() with sensible web defaults. + * + * @example + * ```ts + * import { createWebDiagnostics } from '@bytelyst/diagnostics-client'; + * + * const { init, stop } = createWebDiagnostics({ + * productId: 'nomgap', + * channel: 'nomgap_web', + * serverUrl: 'http://localhost:4003', + * getAuthToken: () => localStorage.getItem('nomgap_access_token') ?? '', + * }); + * export { init as initDiagnostics, stop as stopDiagnostics }; + * ``` + */ + +import { DiagnosticsClient } from './client.js'; + +export interface WebDiagnosticsConfig { + /** Product identifier (e.g. 'nomgap', 'chronomind'). */ + productId: string; + /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ + channel: string; + /** Platform-service origin URL (no trailing /api). */ + serverUrl: string; + /** Function that returns the current auth token. */ + getAuthToken: () => string; + /** App version string. Default: '0.1.0'. */ + appVersion?: string; + /** Build number. Default: '1'. */ + buildNumber?: string; + /** Release channel. Default: 'dev'. */ + releaseChannel?: string; + /** OS family. Default: 'unknown'. */ + osFamily?: string; + /** Poll interval in ms. Default: 30000. */ + pollIntervalMs?: number; + /** Capture console logs. Default: false. */ + captureConsole?: boolean; + /** Capture JS errors. Default: true. */ + captureErrors?: boolean; + /** Capture network requests. Default: false. */ + captureNetwork?: boolean; +} + +export interface WebDiagnostics { + /** Initialize diagnostics. Safe to call on server (no-ops). Idempotent. */ + init(): void; + /** Stop diagnostics polling. */ + stop(): void; +} + +function getOrCreateInstallId(productId: string): string { + const key = `${productId}_diag_install_id`; + let id = localStorage.getItem(key); + if (!id) { + id = + typeof crypto?.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + Date.now().toString(36); + localStorage.setItem(key, id); + } + return id; +} + +export function createWebDiagnostics(config: WebDiagnosticsConfig): WebDiagnostics { + let started = false; + + function init(): void { + if (typeof window === 'undefined') return; + if (started) return; + + DiagnosticsClient.getInstance({ + productId: config.productId, + anonymousInstallId: getOrCreateInstallId(config.productId), + platform: 'web', + channel: config.channel, + osFamily: config.osFamily ?? 'unknown', + appVersion: config.appVersion ?? '0.1.0', + buildNumber: config.buildNumber ?? '1', + releaseChannel: config.releaseChannel ?? 'dev', + serverUrl: config.serverUrl, + getAuthToken: config.getAuthToken, + pollIntervalMs: config.pollIntervalMs ?? 30_000, + captureConsole: config.captureConsole ?? false, + captureErrors: config.captureErrors ?? true, + captureNetwork: config.captureNetwork ?? false, + }); + + DiagnosticsClient.getInstance() + .start() + .catch(() => { + // Diagnostics is best-effort + }); + started = true; + } + + function stop(): void { + try { + DiagnosticsClient.getInstance().stop(); + } catch { + // not initialized + } + } + + return { init, stop }; +} diff --git a/vendor/bytelyst/diagnostics-client/tsconfig.json b/vendor/bytelyst/diagnostics-client/tsconfig.json new file mode 100644 index 0000000..c63c563 --- /dev/null +++ b/vendor/bytelyst/diagnostics-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/event-store/package.json b/vendor/bytelyst/event-store/package.json new file mode 100644 index 0000000..5f94b35 --- /dev/null +++ b/vendor/bytelyst/event-store/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/event-store", + "version": "0.1.5", + "description": "Persistent event store with pluggable backends (in-memory, file, Cosmos) for ByteLyst product backends", + "type": "module", + "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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/event-store/src/file-store.test.ts b/vendor/bytelyst/event-store/src/file-store.test.ts new file mode 100644 index 0000000..1ee1810 --- /dev/null +++ b/vendor/bytelyst/event-store/src/file-store.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { rm } from 'node:fs/promises'; +import { FileEventStore } from './file-store.js'; +import type { StoredEvent } from './types.js'; + +function makeEvent(overrides?: Partial): StoredEvent { + return { + id: crypto.randomUUID(), + type: 'test.event', + userId: 'u1', + productId: 'testprod', + timestamp: new Date().toISOString(), + payload: {}, + ...overrides, + }; +} + +describe('FileEventStore', () => { + let store: FileEventStore; + let filePath: string; + + beforeEach(() => { + filePath = join( + tmpdir(), + `event-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl` + ); + store = new FileEventStore({ filePath }); + }); + + afterEach(async () => { + try { + await rm(filePath); + } catch { + /* may not exist */ + } + }); + + it('appends and retrieves events', async () => { + await store.append(makeEvent()); + expect(await store.count()).toBe(1); + const recent = await store.recent(); + expect(recent).toHaveLength(1); + }); + + it('persists multiple events across reads', async () => { + await store.append(makeEvent({ id: 'e1' })); + await store.append(makeEvent({ id: 'e2' })); + await store.append(makeEvent({ id: 'e3' })); + expect(await store.count()).toBe(3); + const recent = await store.recent(2); + expect(recent).toHaveLength(2); + expect(recent[0].id).toBe('e2'); + }); + + it('queries by userId', async () => { + await store.append(makeEvent({ userId: 'u1' })); + await store.append(makeEvent({ userId: 'u2' })); + await store.append(makeEvent({ userId: 'u1' })); + const results = await store.query({ userId: 'u1' }); + expect(results).toHaveLength(2); + }); + + it('queries by type', async () => { + await store.append(makeEvent({ type: 'task.created' })); + await store.append(makeEvent({ type: 'schedule.generated' })); + const results = await store.query({ type: 'task.created' }); + expect(results).toHaveLength(1); + }); + + it('queries with time range', async () => { + await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); + await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); + await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); + const results = await store.query({ + after: '2026-02-01T00:00:00Z', + before: '2026-04-01T00:00:00Z', + }); + expect(results).toHaveLength(1); + }); + + it('queries with limit', async () => { + for (let i = 0; i < 10; i++) { + await store.append(makeEvent()); + } + const results = await store.query({ limit: 3 }); + expect(results).toHaveLength(3); + }); + + it('clears all events', async () => { + await store.append(makeEvent()); + await store.append(makeEvent()); + await store.clear(); + expect(await store.count()).toBe(0); + }); + + it('returns empty for non-existent file', async () => { + const fresh = new FileEventStore({ + filePath: join(tmpdir(), 'nonexistent-' + Date.now() + '.jsonl'), + }); + expect(await fresh.count()).toBe(0); + expect(await fresh.recent()).toEqual([]); + }); + + it('creates parent directory if needed', async () => { + const nested = join(tmpdir(), `nested-${Date.now()}`, 'sub', 'events.jsonl'); + const nestedStore = new FileEventStore({ filePath: nested }); + await nestedStore.append(makeEvent()); + expect(await nestedStore.count()).toBe(1); + await rm(join(tmpdir(), `nested-${Date.now()}`), { recursive: true }).catch(() => {}); + }); +}); diff --git a/vendor/bytelyst/event-store/src/file-store.ts b/vendor/bytelyst/event-store/src/file-store.ts new file mode 100644 index 0000000..88b8df1 --- /dev/null +++ b/vendor/bytelyst/event-store/src/file-store.ts @@ -0,0 +1,68 @@ +/** + * File-based event store implementation. + * Appends events as JSON lines to a file on disk. + * Suitable for single-instance dev/staging deployments. + */ + +import { readFile, appendFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; + +export interface FileStoreOptions { + filePath: string; +} + +export class FileEventStore implements EventStore { + private readonly filePath: string; + + constructor(options: FileStoreOptions) { + this.filePath = options.filePath; + } + + async append(event: StoredEvent): Promise { + await mkdir(dirname(this.filePath), { recursive: true }); + await appendFile(this.filePath, JSON.stringify(event) + '\n', 'utf-8'); + } + + async query(q: EventStoreQuery): Promise { + const all = await this.readAll(); + let results = all; + + if (q.userId) results = results.filter(e => e.userId === q.userId); + if (q.type) results = results.filter(e => e.type === q.type); + if (q.after) results = results.filter(e => e.timestamp > q.after!); + if (q.before) results = results.filter(e => e.timestamp < q.before!); + + if (q.limit && q.limit > 0) { + results = results.slice(-q.limit); + } + + return results; + } + + async recent(limit = 50): Promise { + const all = await this.readAll(); + return all.slice(-limit); + } + + async count(): Promise { + const all = await this.readAll(); + return all.length; + } + + async clear(): Promise { + await writeFile(this.filePath, '', 'utf-8'); + } + + private async readAll(): Promise { + try { + const content = await readFile(this.filePath, 'utf-8'); + return content + .split('\n') + .filter(line => line.trim()) + .map(line => JSON.parse(line) as StoredEvent); + } catch { + return []; + } + } +} diff --git a/vendor/bytelyst/event-store/src/index.ts b/vendor/bytelyst/event-store/src/index.ts new file mode 100644 index 0000000..594f451 --- /dev/null +++ b/vendor/bytelyst/event-store/src/index.ts @@ -0,0 +1,5 @@ +export type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; +export { MemoryEventStore } from './memory-store.js'; +export type { MemoryStoreOptions } from './memory-store.js'; +export { FileEventStore } from './file-store.js'; +export type { FileStoreOptions } from './file-store.js'; diff --git a/vendor/bytelyst/event-store/src/memory-store.test.ts b/vendor/bytelyst/event-store/src/memory-store.test.ts new file mode 100644 index 0000000..953abfc --- /dev/null +++ b/vendor/bytelyst/event-store/src/memory-store.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryEventStore } from './memory-store.js'; +import type { StoredEvent } from './types.js'; + +function makeEvent(overrides?: Partial): StoredEvent { + return { + id: crypto.randomUUID(), + type: 'test.event', + userId: 'u1', + productId: 'testprod', + timestamp: new Date().toISOString(), + payload: {}, + ...overrides, + }; +} + +describe('MemoryEventStore', () => { + let store: MemoryEventStore; + + beforeEach(() => { + store = new MemoryEventStore({ maxEvents: 100 }); + }); + + it('appends and retrieves events', async () => { + await store.append(makeEvent()); + expect(await store.count()).toBe(1); + const recent = await store.recent(); + expect(recent).toHaveLength(1); + }); + + it('caps at maxEvents', async () => { + for (let i = 0; i < 120; i++) { + await store.append(makeEvent({ id: `e${i}` })); + } + expect(await store.count()).toBe(100); + }); + + it('queries by userId', async () => { + await store.append(makeEvent({ userId: 'u1' })); + await store.append(makeEvent({ userId: 'u2' })); + await store.append(makeEvent({ userId: 'u1' })); + const results = await store.query({ userId: 'u1' }); + expect(results).toHaveLength(2); + }); + + it('queries by type', async () => { + await store.append(makeEvent({ type: 'task.created' })); + await store.append(makeEvent({ type: 'schedule.generated' })); + await store.append(makeEvent({ type: 'task.created' })); + const results = await store.query({ type: 'task.created' }); + expect(results).toHaveLength(2); + }); + + it('queries with time range', async () => { + await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); + await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); + await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); + const results = await store.query({ + after: '2026-02-01T00:00:00Z', + before: '2026-04-01T00:00:00Z', + }); + expect(results).toHaveLength(1); + }); + + it('queries with limit', async () => { + for (let i = 0; i < 10; i++) { + await store.append(makeEvent()); + } + const results = await store.query({ limit: 3 }); + expect(results).toHaveLength(3); + }); + + it('clears all events', async () => { + await store.append(makeEvent()); + await store.append(makeEvent()); + await store.clear(); + expect(await store.count()).toBe(0); + }); + + it('recent returns last N events', async () => { + for (let i = 0; i < 10; i++) { + await store.append(makeEvent({ id: `e${i}` })); + } + const recent = await store.recent(3); + expect(recent).toHaveLength(3); + expect(recent[0].id).toBe('e7'); + }); +}); diff --git a/vendor/bytelyst/event-store/src/memory-store.ts b/vendor/bytelyst/event-store/src/memory-store.ts new file mode 100644 index 0000000..69ab390 --- /dev/null +++ b/vendor/bytelyst/event-store/src/memory-store.ts @@ -0,0 +1,54 @@ +/** + * In-memory event store implementation. + * Useful for development, testing, and as a fallback when no persistent backend is configured. + * Caps at maxEvents to prevent unbounded memory growth. + */ + +import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; + +export interface MemoryStoreOptions { + maxEvents?: number; +} + +export class MemoryEventStore implements EventStore { + private events: StoredEvent[] = []; + private readonly maxEvents: number; + + constructor(options?: MemoryStoreOptions) { + this.maxEvents = options?.maxEvents ?? 10_000; + } + + async append(event: StoredEvent): Promise { + this.events.push(event); + if (this.events.length > this.maxEvents) { + this.events = this.events.slice(-this.maxEvents); + } + } + + async query(q: EventStoreQuery): Promise { + let results = this.events; + + if (q.userId) results = results.filter(e => e.userId === q.userId); + if (q.type) results = results.filter(e => e.type === q.type); + if (q.after) results = results.filter(e => e.timestamp > q.after!); + if (q.before) results = results.filter(e => e.timestamp < q.before!); + + if (q.limit && q.limit > 0) { + results = results.slice(-q.limit); + } + + return results; + } + + async recent(limit = 50): Promise { + return this.events.slice(-limit); + } + + async count(): Promise { + return this.events.length; + } + + async clear(): Promise { + this.events = []; + } +} diff --git a/vendor/bytelyst/event-store/src/types.ts b/vendor/bytelyst/event-store/src/types.ts new file mode 100644 index 0000000..5b11418 --- /dev/null +++ b/vendor/bytelyst/event-store/src/types.ts @@ -0,0 +1,29 @@ +/** + * Pluggable event store interface for ByteLyst product backends. + * Products define their own event shapes; the store handles persistence. + */ + +export interface StoredEvent { + id: string; + type: string; + userId: string; + productId: string; + timestamp: string; + payload: Record; +} + +export interface EventStoreQuery { + userId?: string; + type?: string; + after?: string; + before?: string; + limit?: number; +} + +export interface EventStore { + append(event: StoredEvent): Promise; + query(q: EventStoreQuery): Promise; + recent(limit?: number): Promise; + count(): Promise; + clear(): Promise; +} diff --git a/vendor/bytelyst/event-store/tsconfig.json b/vendor/bytelyst/event-store/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/event-store/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json new file mode 100644 index 0000000..97a233a --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json @@ -0,0 +1,27 @@ +{ + "eventId": "evt_phase1_002", + "eventName": "artifact.created", + "eventVersion": 1, + "occurredAt": "2026-04-03T18:17:01.000Z", + "productId": "notelett", + "sourceSurface": "web", + "userId": "user_saravana", + "orgId": null, + "sessionId": "sess_lysnr_001", + "runId": "run_notes_001", + "artifactId": "art_note_001", + "actor": { + "actorType": "agent", + "actorId": "notes_ingest_agent" + }, + "trace": { + "correlationId": "corr_phase1_001", + "causationId": "evt_phase1_001", + "parentEventId": "evt_phase1_001" + }, + "payload": { + "artifactType": "note", + "title": "Standup follow-up", + "status": "draft" + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json new file mode 100644 index 0000000..14afb36 --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json @@ -0,0 +1,27 @@ +{ + "eventId": "evt_phase1_003", + "eventName": "artifact.linked", + "eventVersion": 1, + "occurredAt": "2026-04-03T18:17:02.000Z", + "productId": "notelett", + "sourceSurface": "web", + "userId": "user_saravana", + "orgId": null, + "sessionId": "sess_lysnr_001", + "runId": "run_notes_001", + "artifactId": "art_note_001", + "actor": { + "actorType": "agent", + "actorId": "notes_ingest_agent" + }, + "trace": { + "correlationId": "corr_phase1_001", + "causationId": "evt_phase1_002", + "parentEventId": "evt_phase1_002" + }, + "payload": { + "sourceArtifactId": "art_note_001", + "targetArtifactId": "art_transcript_001", + "relation": "summarizes" + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json new file mode 100644 index 0000000..a1bd3f2 --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json @@ -0,0 +1,28 @@ +{ + "eventId": "evt_phase1_001", + "eventName": "capture.transcript.created", + "eventVersion": 1, + "occurredAt": "2026-04-03T18:15:01.000Z", + "productId": "lysnrai", + "sourceSurface": "mobile", + "userId": "user_saravana", + "orgId": null, + "sessionId": "sess_lysnr_001", + "runId": null, + "artifactId": "art_transcript_001", + "actor": { + "actorType": "user", + "actorId": "user_saravana" + }, + "trace": { + "correlationId": "corr_phase1_001", + "causationId": null, + "parentEventId": null + }, + "payload": { + "artifactId": "art_transcript_001", + "durationMs": 42150, + "language": "en", + "transcriptSource": "microphone" + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json new file mode 100644 index 0000000..44d219d --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json @@ -0,0 +1,69 @@ +{ + "id": "art_memory_001", + "artifactType": "memory", + "schemaVersion": 1, + "productId": "mindlyst", + "sourceSurface": "service", + "title": "Saravana prefers deployment checklist reminders after standup", + "summary": "Memory candidate inferred from the standup transcript and note.", + "createdAt": "2026-04-03T18:20:00.000Z", + "updatedAt": "2026-04-03T18:20:00.000Z", + "createdBy": { + "actorType": "agent", + "actorId": "memory_ingest_agent" + }, + "ownership": { + "userId": "user_saravana", + "orgId": null + }, + "visibility": { + "scope": "private" + }, + "status": "proposed", + "tags": ["memory", "workflow"], + "links": [ + { + "relation": "derived-from", + "targetArtifactId": "art_transcript_001" + }, + { + "relation": "generated-memory", + "targetArtifactId": "art_note_001" + } + ], + "provenance": { + "originProductId": "lysnrai", + "originActionId": "capture_001", + "sessionId": "sess_lysnr_001", + "runId": "run_memory_001", + "approvalId": null, + "correlationId": "corr_phase1_001", + "lineage": [ + { + "stepType": "captured", + "productId": "lysnrai", + "actorType": "user", + "timestamp": "2026-04-03T18:15:00.000Z" + }, + { + "stepType": "note-created", + "productId": "notelett", + "actorType": "agent", + "timestamp": "2026-04-03T18:17:00.000Z" + }, + { + "stepType": "memory-proposed", + "productId": "mindlyst", + "actorType": "agent", + "timestamp": "2026-04-03T18:20:00.000Z" + } + ] + }, + "payload": { + "memoryKind": "preference", + "text": "Saravana benefits from deployment checklist reminders immediately after standup capture.", + "confidence": 0.82, + "sourceArtifactIds": ["art_transcript_001", "art_note_001"], + "reviewState": "proposed" + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json new file mode 100644 index 0000000..81bfd92 --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json @@ -0,0 +1,29 @@ +{ + "eventId": "evt_phase1_004", + "eventName": "memory.entry.created", + "eventVersion": 1, + "occurredAt": "2026-04-03T18:20:01.000Z", + "productId": "mindlyst", + "sourceSurface": "service", + "userId": "user_saravana", + "orgId": null, + "sessionId": "sess_lysnr_001", + "runId": "run_memory_001", + "artifactId": "art_memory_001", + "actor": { + "actorType": "agent", + "actorId": "memory_ingest_agent" + }, + "trace": { + "correlationId": "corr_phase1_001", + "causationId": "evt_phase1_003", + "parentEventId": "evt_phase1_003" + }, + "payload": { + "artifactId": "art_memory_001", + "memoryKind": "preference", + "reviewState": "proposed", + "confidence": 0.82, + "sourceArtifactIds": ["art_transcript_001", "art_note_001"] + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json new file mode 100644 index 0000000..c785876 --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json @@ -0,0 +1,57 @@ +{ + "id": "art_note_001", + "artifactType": "note", + "schemaVersion": 1, + "productId": "notelett", + "sourceSurface": "web", + "title": "Standup follow-up", + "summary": "Structured note created from a LysnrAI transcript.", + "createdAt": "2026-04-03T18:17:00.000Z", + "updatedAt": "2026-04-03T18:17:00.000Z", + "createdBy": { + "actorType": "agent", + "actorId": "notes_ingest_agent" + }, + "ownership": { + "userId": "user_saravana", + "orgId": null + }, + "visibility": { + "scope": "private" + }, + "status": "draft", + "tags": ["derived", "standup"], + "links": [ + { + "relation": "summarizes", + "targetArtifactId": "art_transcript_001" + } + ], + "provenance": { + "originProductId": "lysnrai", + "originActionId": "capture_001", + "sessionId": "sess_lysnr_001", + "runId": "run_notes_001", + "approvalId": null, + "correlationId": "corr_phase1_001", + "lineage": [ + { + "stepType": "captured", + "productId": "lysnrai", + "actorType": "user", + "timestamp": "2026-04-03T18:15:00.000Z" + }, + { + "stepType": "note-created", + "productId": "notelett", + "actorType": "agent", + "timestamp": "2026-04-03T18:17:00.000Z" + } + ] + }, + "payload": { + "noteFormat": "markdown", + "body": "# Standup follow-up\n\n- Billing sync finished\n- Clean up follow-up notes\n- Review deployment checklist", + "excerpt": "Billing sync finished; follow-up notes and deployment checklist remain." + } +} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json new file mode 100644 index 0000000..f696a05 --- /dev/null +++ b/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json @@ -0,0 +1,56 @@ +{ + "id": "art_transcript_001", + "artifactType": "transcript", + "schemaVersion": 1, + "productId": "lysnrai", + "sourceSurface": "mobile", + "title": "Daily standup voice capture", + "summary": "Transcript captured from Saravana's daily standup reflection.", + "createdAt": "2026-04-03T18:15:00.000Z", + "updatedAt": "2026-04-03T18:15:00.000Z", + "createdBy": { + "actorType": "user", + "actorId": "user_saravana" + }, + "ownership": { + "userId": "user_saravana", + "orgId": null + }, + "visibility": { + "scope": "private", + "allowedProducts": ["learning_ai_notes", "learning_multimodal_memory_agents"] + }, + "status": "completed", + "tags": ["voice", "standup"], + "links": [], + "provenance": { + "originProductId": "lysnrai", + "originActionId": "capture_001", + "sessionId": "sess_lysnr_001", + "runId": null, + "approvalId": null, + "correlationId": "corr_phase1_001", + "lineage": [ + { + "stepType": "captured", + "productId": "lysnrai", + "actorType": "user", + "timestamp": "2026-04-03T18:15:00.000Z" + } + ] + }, + "payload": { + "transcriptText": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist.", + "transcriptSource": "microphone", + "language": "en", + "durationMs": 42150, + "segments": [ + { + "speaker": null, + "startedAtMs": 0, + "endedAtMs": 42150, + "text": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist." + } + ] + } +} diff --git a/vendor/bytelyst/events/package.json b/vendor/bytelyst/events/package.json new file mode 100644 index 0000000..444153f --- /dev/null +++ b/vendor/bytelyst/events/package.json @@ -0,0 +1,33 @@ +{ + "name": "@bytelyst/events", + "version": "0.1.5", + "type": "module", + "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 --pool forks" + }, + "dependencies": { + "@bytelyst/queue": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/events/src/agent-runtime.test.ts b/vendor/bytelyst/events/src/agent-runtime.test.ts new file mode 100644 index 0000000..8ae570d --- /dev/null +++ b/vendor/bytelyst/events/src/agent-runtime.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { + AgentActionLogSchema, + AgentCheckpointSchema, + AgentApprovalCheckpointSchema, + AgentDispatchRequestSchema, + AgentRunSchema, + AgentSessionSchema, + AgentTaskSchema, + AgentTodoSchema, +} from './agent-runtime.js'; + +describe('agent runtime contract baseline', () => { + it('validates a dispatched Cowork session with task and approval checkpoint', () => { + const session = AgentSessionSchema.parse({ + sessionId: 'sess_cowork_1', + productId: 'cowork', + userId: 'saravana', + status: 'waiting-approval', + startedAt: '2026-04-03T16:00:00.000Z', + updatedAt: '2026-04-03T16:05:00.000Z', + resumable: true, + currentTaskId: 'task_cowork_1', + memoryRefs: ['mem_proj_1'], + artifactRefs: ['art_note_1'], + approvalRefs: ['approval_1'], + dispatchContext: { + originSurface: 'browser', + originProductId: 'notelett', + dispatchMode: 'remote', + initiatedAt: '2026-04-03T15:59:00.000Z', + }, + }); + + const task = AgentTaskSchema.parse({ + taskId: 'task_cowork_1', + sessionId: session.sessionId, + title: 'Investigate imported roadmap risks', + intent: 'Audit the imported note and produce findings', + status: 'blocked', + priority: 'high', + createdAt: '2026-04-03T16:00:00.000Z', + updatedAt: '2026-04-03T16:05:00.000Z', + }); + + const approval = AgentApprovalCheckpointSchema.parse({ + approvalId: 'approval_1', + sessionId: session.sessionId, + runId: 'run_cowork_1', + actionLabel: 'Delete temporary export', + riskLevel: 'high', + status: 'requested', + requestedAt: '2026-04-03T16:05:00.000Z', + resolvedAt: null, + resolverSurface: null, + }); + + expect(session.dispatchContext?.originSurface).toBe('browser'); + expect(task.status).toBe('blocked'); + expect(approval.status).toBe('requested'); + }); + + it('validates a scheduled FlowMonk run with todos and action logs', () => { + const dispatch = AgentDispatchRequestSchema.parse({ + dispatchId: 'dispatch_flow_1', + targetProductId: 'flowmonk', + targetExecutor: 'flowmonk', + userId: 'saravana', + title: 'Execute weekly planning refresh', + intent: 'Rebuild plan, routines, and habits for next week', + artifactRefs: ['art_plan_template_1'], + memoryRefs: ['mem_goal_1'], + dispatchContext: { + originSurface: 'web', + originProductId: 'flowmonk', + dispatchMode: 'scheduled', + initiatedAt: '2026-04-03T17:00:00.000Z', + }, + }); + + const run = AgentRunSchema.parse({ + runId: 'run_flow_1', + sessionId: 'sess_flow_1', + productId: 'flowmonk', + status: 'waiting-approval', + startedAt: '2026-04-03T17:01:00.000Z', + completedAt: null, + checkpointArtifactId: 'art_plan_1', + correlationId: 'corr_phase2', + }); + + const todo = AgentTodoSchema.parse({ + todoId: 'todo_flow_1', + sessionId: 'sess_flow_1', + text: 'Generate routines from approved plan', + status: 'in-progress', + createdAt: '2026-04-03T17:01:30.000Z', + updatedAt: '2026-04-03T17:02:00.000Z', + }); + + const actionLog = AgentActionLogSchema.parse({ + actionLogId: 'alog_flow_1', + sessionId: 'sess_flow_1', + runId: 'run_flow_1', + eventName: 'agent.run.started', + occurredAt: '2026-04-03T17:01:00.000Z', + actorType: 'agent', + correlationId: 'corr_phase2', + payload: { + dispatchId: dispatch.dispatchId, + title: dispatch.title, + }, + }); + + expect(dispatch.dispatchContext.dispatchMode).toBe('scheduled'); + expect(run.status).toBe('waiting-approval'); + expect(run.checkpointArtifactId).toBe('art_plan_1'); + expect(todo.status).toBe('in-progress'); + expect(actionLog.eventName).toBe('agent.run.started'); + }); + + it('accepts queued runs as a first-class runtime state', () => { + const run = AgentRunSchema.parse({ + runId: 'run_queued_1', + sessionId: 'sess_queued_1', + productId: 'clawcowork', + status: 'queued', + startedAt: '2026-04-04T10:00:00.000Z', + completedAt: null, + checkpointArtifactId: null, + correlationId: 'corr_queued_1', + }); + + expect(run.status).toBe('queued'); + }); + + it('validates checkpoint summaries used for resume review', () => { + const checkpoint = AgentCheckpointSchema.parse({ + checkpointId: 'ckpt_cowork_1', + sessionId: 'sess_cowork_1', + runId: 'run_cowork_1', + productId: 'clawcowork', + userId: 'saravana', + createdAt: '2026-04-04T12:00:00.000Z', + statusAtCapture: 'waiting-approval', + currentTaskId: 'task_cowork_1', + checkpointArtifactId: 'artifact://notelett/note-1', + todoIds: ['todo_cowork_1'], + artifactRefs: ['artifact://notelett/note-1'], + memoryRefs: [], + approvalRefs: ['approval_1'], + dispatchContext: { + originSurface: 'desktop', + originProductId: 'clawcowork', + dispatchMode: 'interactive', + initiatedAt: '2026-04-04T11:58:00.000Z', + }, + resumeToken: 'task_cowork_1', + stateSummary: { + title: 'Investigate imported roadmap risks', + summary: 'Paused for approval after scanning the repository and proposing changes.', + lastActionAt: '2026-04-04T12:00:00.000Z', + }, + }); + + expect(checkpoint.statusAtCapture).toBe('waiting-approval'); + expect(checkpoint.resumeToken).toBe('task_cowork_1'); + expect(checkpoint.checkpointArtifactId).toBe('artifact://notelett/note-1'); + }); +}); diff --git a/vendor/bytelyst/events/src/agent-runtime.ts b/vendor/bytelyst/events/src/agent-runtime.ts new file mode 100644 index 0000000..5ba0ec9 --- /dev/null +++ b/vendor/bytelyst/events/src/agent-runtime.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; + +export const AgentDispatchContextSchema = z.object({ + originSurface: z.enum(['browser', 'mobile', 'desktop', 'web', 'product-api']), + originProductId: z.string().min(1), + dispatchMode: z.enum(['interactive', 'queued', 'scheduled', 'remote']), + initiatedAt: z.string().datetime(), +}); + +export const AgentSessionStatusSchema = z.enum([ + 'active', + 'paused', + 'waiting-approval', + 'completed', + 'failed', + 'cancelled', +]); + +export const AgentSessionSchema = z.object({ + sessionId: z.string().min(1), + productId: z.string().min(1), + userId: z.string().min(1), + status: AgentSessionStatusSchema, + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), + resumable: z.boolean(), + currentTaskId: z.string().min(1).nullable().optional(), + memoryRefs: z.array(z.string().min(1)), + artifactRefs: z.array(z.string().min(1)), + approvalRefs: z.array(z.string().min(1)), + dispatchContext: AgentDispatchContextSchema.nullable().optional(), +}); + +export const AgentTaskStatusSchema = z.enum([ + 'queued', + 'running', + 'blocked', + 'completed', + 'failed', + 'cancelled', +]); + +export const AgentTaskSchema = z.object({ + taskId: z.string().min(1), + sessionId: z.string().min(1), + title: z.string().min(1), + intent: z.string().min(1), + status: AgentTaskStatusSchema, + priority: z.string().min(1).nullable().optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const AgentTodoStatusSchema = z.enum(['open', 'in-progress', 'done', 'dropped']); + +export const AgentTodoSchema = z.object({ + todoId: z.string().min(1), + sessionId: z.string().min(1), + text: z.string().min(1), + status: AgentTodoStatusSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const AgentCheckpointStatusSchema = z.enum([ + 'queued', + 'running', + 'paused', + 'waiting-approval', + 'completed', + 'failed', + 'cancelled', +]); + +export const AgentCheckpointSchema = z.object({ + checkpointId: z.string().min(1), + sessionId: z.string().min(1), + runId: z.string().min(1).nullable().optional(), + productId: z.string().min(1), + userId: z.string().min(1), + createdAt: z.string().datetime(), + statusAtCapture: AgentCheckpointStatusSchema, + currentTaskId: z.string().min(1).nullable().optional(), + checkpointArtifactId: z.string().min(1).nullable().optional(), + todoIds: z.array(z.string().min(1)), + artifactRefs: z.array(z.string().min(1)), + memoryRefs: z.array(z.string().min(1)), + approvalRefs: z.array(z.string().min(1)), + dispatchContext: AgentDispatchContextSchema.nullable().optional(), + resumeToken: z.string().min(1).nullable().optional(), + stateSummary: z.object({ + title: z.string().min(1), + summary: z.string().min(1), + lastActionAt: z.string().datetime().nullable().optional(), + }), +}); + +export const AgentRunStatusSchema = z.enum([ + 'queued', + 'running', + 'paused', + 'waiting-approval', + 'completed', + 'failed', + 'cancelled', +]); + +export const AgentRunSchema = z.object({ + runId: z.string().min(1), + sessionId: z.string().min(1), + productId: z.string().min(1), + status: AgentRunStatusSchema, + startedAt: z.string().datetime(), + completedAt: z.string().datetime().nullable().optional(), + checkpointArtifactId: z.string().min(1).nullable().optional(), + correlationId: z.string().min(1).nullable().optional(), +}); + +export const AgentApprovalCheckpointSchema = z.object({ + approvalId: z.string().min(1), + sessionId: z.string().min(1), + runId: z.string().min(1), + actionLabel: z.string().min(1), + riskLevel: z.enum(['low', 'medium', 'high', 'critical']), + status: z.enum(['requested', 'approved', 'denied', 'expired']), + requestedAt: z.string().datetime(), + resolvedAt: z.string().datetime().nullable().optional(), + resolverSurface: z.enum(['mobile', 'web', 'desktop']).nullable().optional(), +}); + +export const AgentDispatchRequestSchema = z.object({ + dispatchId: z.string().min(1), + targetProductId: z.string().min(1), + targetExecutor: z.enum(['cowork', 'jarvisjr', 'flowmonk', 'generic-agent']), + userId: z.string().min(1), + title: z.string().min(1), + intent: z.string().min(1), + artifactRefs: z.array(z.string().min(1)).default([]), + memoryRefs: z.array(z.string().min(1)).default([]), + dispatchContext: AgentDispatchContextSchema, +}); + +export const AgentActionLogSchema = z.object({ + actionLogId: z.string().min(1), + sessionId: z.string().min(1), + runId: z.string().min(1), + eventName: z.string().min(1), + occurredAt: z.string().datetime(), + actorType: z.enum(['user', 'agent', 'system', 'device']), + correlationId: z.string().min(1).nullable().optional(), + payload: z.record(z.unknown()), +}); + +export type AgentDispatchContext = z.infer; +export type AgentSession = z.infer; +export type AgentTask = z.infer; +export type AgentTodo = z.infer; +export type AgentCheckpoint = z.infer; +export type AgentRun = z.infer; +export type AgentApprovalCheckpoint = z.infer; +export type AgentDispatchRequest = z.infer; +export type AgentActionLog = z.infer; diff --git a/vendor/bytelyst/events/src/durable.test.ts b/vendor/bytelyst/events/src/durable.test.ts new file mode 100644 index 0000000..b2733a7 --- /dev/null +++ b/vendor/bytelyst/events/src/durable.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it, expect, vi } from 'vitest'; +import { FileQueueStore } from '@bytelyst/queue'; +import { MemoryQueueStore } from '@bytelyst/queue'; +import { DurableEventBus } from './durable.js'; + +describe('DurableEventBus', () => { + it('delivers queued events through the worker', async () => { + const store = new MemoryQueueStore(); + const bus = new DurableEventBus({ + store, + autoStart: false, + pollIntervalMs: 10, + }); + + const handler = vi.fn(); + bus.on('user.created', handler); + bus.start(); + + const result = await bus.emit('user.created', { + userId: 'u1', + email: 'test@example.com', + plan: 'free', + productId: 'lysnrai', + }); + + expect(result.handlerCount).toBe(1); + + await waitFor(() => { + expect(handler).toHaveBeenCalledOnce(); + }); + + await bus.stop(); + }); + + it('persists emitted events across bus instances before the worker starts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'events-store-')); + const filePath = join(dir, 'events.json'); + + try { + const first = new DurableEventBus({ + store: new FileQueueStore({ filePath }), + autoStart: false, + pollIntervalMs: 10, + }); + + await first.emit('payment.failed', { + invoiceId: 'inv_1', + userId: 'u1', + amount: 499, + retryCount: 1, + productId: 'lysnrai', + }); + await first.stop(); + + const second = new DurableEventBus({ + store: new FileQueueStore({ filePath }), + autoStart: false, + pollIntervalMs: 10, + }); + + const handler = vi.fn(); + second.on('payment.failed', handler); + second.start(); + + await waitFor(() => { + expect(handler).toHaveBeenCalledOnce(); + }); + + await second.stop(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + assertion(); + return; + } catch { + await new Promise(resolve => setTimeout(resolve, 20)); + } + } + + assertion(); +} diff --git a/vendor/bytelyst/events/src/durable.ts b/vendor/bytelyst/events/src/durable.ts new file mode 100644 index 0000000..abb57e9 --- /dev/null +++ b/vendor/bytelyst/events/src/durable.ts @@ -0,0 +1,152 @@ +import { QueueWorker, type QueueJob, type QueueStore } from '@bytelyst/queue'; +import type { EmitResult } from './memory.js'; +import type { + EventHandler, + EventSubscription, + PlatformEvent, + PlatformEventName, + PlatformEventPayload, +} from './types.js'; + +interface EventEnvelope { + event: PlatformEvent; +} + +export interface DurableEventBusOptions { + store: QueueStore; + queueName?: string; + workerId?: string; + pollIntervalMs?: number; + leaseMs?: number; + backoffMs?: number; + autoStart?: boolean; +} + +export class DurableEventBus { + private readonly handlers = new Map< + string, + Set<{ id: string; fn: EventHandler }> + >(); + private subscriptionCounter = 0; + private readonly queueName: string; + private readonly store: QueueStore; + private readonly worker: QueueWorker; + private running = false; + + constructor(options: DurableEventBusOptions) { + this.queueName = options.queueName ?? 'platform-events'; + this.store = options.store; + this.worker = new QueueWorker({ + queueName: this.queueName, + store: this.store, + workerId: options.workerId, + pollIntervalMs: options.pollIntervalMs, + leaseMs: options.leaseMs, + backoffMs: options.backoffMs, + handler: async job => { + await this.dispatch(job); + }, + }); + + if (options.autoStart !== false) { + this.start(); + } + } + + on(eventType: T, handler: EventHandler): EventSubscription { + const id = `sub_${++this.subscriptionCounter}`; + + if (!this.handlers.has(eventType)) { + this.handlers.set(eventType, new Set()); + } + + const entry = { id, fn: handler as EventHandler }; + this.handlers.get(eventType)!.add(entry); + + return { + id, + eventType, + unsubscribe: () => { + this.handlers.get(eventType)?.delete(entry); + }, + }; + } + + async emit( + eventType: T, + payload: PlatformEventPayload, + options?: { source?: string } + ): Promise { + const event: PlatformEvent = { + id: crypto.randomUUID(), + type: eventType, + payload, + timestamp: new Date().toISOString(), + source: options?.source, + }; + + await this.store.enqueue(this.queueName, { + idempotencyKey: event.id, + type: eventType, + payload: { event }, + productId: extractProductId(payload), + metadata: { + source: options?.source, + }, + }); + + return { + eventId: event.id, + handlerCount: this.listenerCount(eventType), + errors: [], + }; + } + + start(): void { + if (this.running) return; + this.running = true; + this.worker.start(); + } + + async stop(): Promise { + if (!this.running) return; + this.running = false; + await this.worker.stop(); + } + + clear(eventType?: PlatformEventName): void { + if (eventType) { + this.handlers.delete(eventType); + } else { + this.handlers.clear(); + } + } + + listenerCount(eventType: PlatformEventName): number { + return this.handlers.get(eventType)?.size ?? 0; + } + + eventTypes(): PlatformEventName[] { + return Array.from(this.handlers.entries()) + .filter(([, set]) => set.size > 0) + .map(([type]) => type as PlatformEventName); + } + + private async dispatch(job: QueueJob): Promise { + const event = job.payload.event; + const handlers = this.handlers.get(event.type); + if (!handlers || handlers.size === 0) { + return; + } + + await Promise.allSettled( + Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) + ); + } +} + +function extractProductId(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + const productId = (payload as { productId?: unknown }).productId; + return typeof productId === 'string' ? productId : undefined; +} diff --git a/vendor/bytelyst/events/src/ecosystem.test.ts b/vendor/bytelyst/events/src/ecosystem.test.ts new file mode 100644 index 0000000..6f83cc0 --- /dev/null +++ b/vendor/bytelyst/events/src/ecosystem.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from 'vitest'; +import transcriptArtifact from '../fixtures/ecosystem/phase1/transcript-artifact.json' with { type: 'json' }; +import noteArtifact from '../fixtures/ecosystem/phase1/note-artifact.json' with { type: 'json' }; +import memoryArtifact from '../fixtures/ecosystem/phase1/memory-artifact.json' with { type: 'json' }; +import captureTranscriptCreatedEvent from '../fixtures/ecosystem/phase1/capture-transcript-created.event.json' with { type: 'json' }; +import artifactCreatedEvent from '../fixtures/ecosystem/phase1/artifact-created.event.json' with { type: 'json' }; +import artifactLinkedEvent from '../fixtures/ecosystem/phase1/artifact-linked.event.json' with { type: 'json' }; +import memoryEntryCreatedEvent from '../fixtures/ecosystem/phase1/memory-entry-created.event.json' with { type: 'json' }; +import { + ArtifactCreatedEventSchema, + ArtifactLinkedEventSchema, + HabitArtifactEnvelopeSchema, + PlanArtifactEnvelopeSchema, + Phase1ArtifactEnvelopeSchema, + Phase1EcosystemEventSchema, + Phase1EcosystemEventSchemas, + RoutineArtifactEnvelopeSchema, + TrailReportArtifactEnvelopeSchema, +} from './ecosystem.js'; + +describe('phase1 ecosystem contracts', () => { + it('validates canonical transcript, note, and memory artifacts', () => { + const transcript = Phase1ArtifactEnvelopeSchema.parse(transcriptArtifact); + const note = Phase1ArtifactEnvelopeSchema.parse(noteArtifact); + const memory = Phase1ArtifactEnvelopeSchema.parse(memoryArtifact); + + expect(transcript.artifactType).toBe('transcript'); + expect(note.links).toContainEqual({ + relation: 'summarizes', + targetArtifactId: transcript.id, + }); + expect(memory.links).toEqual( + expect.arrayContaining([ + { + relation: 'generated-memory', + targetArtifactId: note.id, + }, + ]) + ); + }); + + it('validates canonical phase1 events', () => { + const events = [ + captureTranscriptCreatedEvent, + artifactCreatedEvent, + artifactLinkedEvent, + memoryEntryCreatedEvent, + ].map(event => Phase1EcosystemEventSchema.parse(event)); + + expect(events.map(event => event.eventName)).toEqual([ + 'capture.transcript.created', + 'artifact.created', + 'artifact.linked', + 'memory.entry.created', + ]); + }); + + it('exposes event-specific schemas keyed by canonical event name', () => { + const created = Phase1EcosystemEventSchemas['artifact.created'].parse(artifactCreatedEvent); + const linked = Phase1EcosystemEventSchemas['artifact.linked'].parse(artifactLinkedEvent); + + expect(created.payload.artifactType).toBe('note'); + expect(linked.payload.relation).toBe('summarizes'); + }); +}); + +describe('phase2 ecosystem contract extensions', () => { + it('validates canonical plan, routine, and habit artifacts', () => { + const plan = PlanArtifactEnvelopeSchema.parse({ + id: 'art_plan_demo', + artifactType: 'plan', + schemaVersion: 1, + productId: 'flowmonk', + sourceSurface: 'backend', + title: 'FlowMonk weekly plan', + summary: 'Three focused execution blocks', + createdAt: '2026-04-03T12:00:00.000Z', + updatedAt: '2026-04-03T12:00:00.000Z', + createdBy: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, + ownership: { userId: 'saravana', orgId: null }, + visibility: { + scope: 'private', + allowedProducts: ['learning_ai_clock', 'learning_ai_efforise'], + }, + status: 'draft', + tags: ['ecosystem', 'phase2', 'plan'], + links: [], + provenance: { + originProductId: 'flowmonk', + originActionId: 'plan_export_1', + sessionId: 'sess_phase2', + runId: 'run_phase2_plan', + approvalId: null, + correlationId: 'corr_phase2', + lineage: [ + { + stepType: 'plan-exported', + productId: 'flowmonk', + actorType: 'agent', + timestamp: '2026-04-03T12:00:00.000Z', + }, + ], + }, + payload: { + weekOf: '2026-04-07', + taskCount: 3, + scheduledEntryCount: 3, + totalScheduledMinutes: 165, + entries: [ + { + taskTitle: 'Architecture review', + scheduledDate: '2026-04-07', + startTime: '08:00', + endTime: '09:00', + durationMinutes: 60, + flowName: 'Deep Work', + zoneName: 'Studio', + priority: 'high', + }, + ], + }, + }); + + const routine = RoutineArtifactEnvelopeSchema.parse({ + id: 'art_routine_demo', + artifactType: 'routine', + schemaVersion: 1, + productId: 'chronomind', + sourceSurface: 'backend', + title: 'Routine from FlowMonk weekly plan', + summary: 'Three-step execution routine', + createdAt: '2026-04-03T12:05:00.000Z', + updatedAt: '2026-04-03T12:05:00.000Z', + createdBy: { actorType: 'agent', actorId: 'phase2-routine-importer' }, + ownership: { userId: 'saravana', orgId: null }, + visibility: { scope: 'private' }, + status: 'ready', + tags: ['ecosystem', 'phase2', 'routine'], + links: [{ relation: 'generated-routine', targetArtifactId: plan.id }], + provenance: { + originProductId: 'flowmonk', + originActionId: 'plan_export_1', + sessionId: 'sess_phase2', + runId: 'run_phase2_routine', + approvalId: null, + correlationId: 'corr_phase2', + lineage: [ + { + stepType: 'plan-exported', + productId: 'flowmonk', + actorType: 'agent', + timestamp: '2026-04-03T12:00:00.000Z', + }, + { + stepType: 'routine-created', + productId: 'chronomind', + actorType: 'agent', + timestamp: '2026-04-03T12:05:00.000Z', + }, + ], + }, + payload: { + routineId: 'routine_demo', + stepCount: 3, + totalDurationMinutes: 165, + status: 'ready', + isTemplate: true, + category: 'phase2-import', + steps: [ + { + label: 'Architecture review', + durationMinutes: 60, + transition: '5m_break', + status: 'pending', + }, + ], + }, + }); + + const habit = HabitArtifactEnvelopeSchema.parse({ + id: 'art_habit_demo', + artifactType: 'habit', + schemaVersion: 1, + productId: 'efforise', + sourceSurface: 'backend', + title: 'Habit from FlowMonk weekly plan', + summary: 'Practice the imported routine daily', + createdAt: '2026-04-03T12:10:00.000Z', + updatedAt: '2026-04-03T12:10:00.000Z', + createdBy: { actorType: 'agent', actorId: 'phase2-habit-importer' }, + ownership: { userId: 'saravana', orgId: null }, + visibility: { scope: 'private' }, + status: 'active', + tags: ['ecosystem', 'phase2', 'habit'], + links: [{ relation: 'generated-habit', targetArtifactId: routine.id }], + provenance: { + originProductId: 'flowmonk', + originActionId: 'plan_export_1', + sessionId: 'sess_phase2', + runId: 'run_phase2_habit', + approvalId: null, + correlationId: 'corr_phase2', + lineage: [ + { + stepType: 'plan-exported', + productId: 'flowmonk', + actorType: 'agent', + timestamp: '2026-04-03T12:00:00.000Z', + }, + { + stepType: 'routine-created', + productId: 'chronomind', + actorType: 'agent', + timestamp: '2026-04-03T12:05:00.000Z', + }, + { + stepType: 'habit-created', + productId: 'efforise', + actorType: 'agent', + timestamp: '2026-04-03T12:10:00.000Z', + }, + ], + }, + payload: { + habitId: 'habit_demo', + identityId: 'identity_phase2', + frequency: 'daily', + targetCount: 1, + reminderTime: '08:00', + isActive: true, + sourceRoutineId: 'routine_demo', + }, + }); + + expect(routine.links[0]?.targetArtifactId).toBe(plan.id); + expect(habit.links[0]?.targetArtifactId).toBe(routine.id); + }); + + it('accepts generic artifact.created and artifact.linked events for phase2 artifact types', () => { + const created = ArtifactCreatedEventSchema.parse({ + eventId: 'evt_phase2_created', + eventName: 'artifact.created', + eventVersion: 1, + occurredAt: '2026-04-03T12:05:00.000Z', + productId: 'chronomind', + sourceSurface: 'backend', + userId: 'saravana', + orgId: null, + sessionId: 'sess_phase2', + runId: 'run_phase2_routine', + artifactId: 'art_routine_demo', + actor: { actorType: 'agent', actorId: 'phase2-routine-importer' }, + trace: { + correlationId: 'corr_phase2', + causationId: 'evt_phase2_plan', + parentEventId: 'evt_phase2_plan', + }, + payload: { + artifactType: 'routine', + title: 'Routine from FlowMonk weekly plan', + status: 'ready', + }, + }); + + const linked = ArtifactLinkedEventSchema.parse({ + eventId: 'evt_phase2_linked', + eventName: 'artifact.linked', + eventVersion: 1, + occurredAt: '2026-04-03T12:10:00.000Z', + productId: 'efforise', + sourceSurface: 'backend', + userId: 'saravana', + orgId: null, + sessionId: 'sess_phase2', + runId: 'run_phase2_habit', + artifactId: 'art_habit_demo', + actor: { actorType: 'agent', actorId: 'phase2-habit-importer' }, + trace: { + correlationId: 'corr_phase2', + causationId: 'evt_phase2_created', + parentEventId: 'evt_phase2_created', + }, + payload: { + sourceArtifactId: 'art_habit_demo', + targetArtifactId: 'art_routine_demo', + relation: 'generated-habit', + }, + }); + + expect(created.payload.artifactType).toBe('routine'); + expect(linked.payload.relation).toBe('generated-habit'); + }); +}); + +describe('phase3 ecosystem contract extensions', () => { + it('validates canonical trail-report artifacts', () => { + const trailReport = TrailReportArtifactEnvelopeSchema.parse({ + id: 'art_trail_demo', + artifactType: 'trail-report', + schemaVersion: 1, + productId: 'actiontrail', + sourceSurface: 'backend', + title: 'Cowork audit report for task task-123', + summary: '4 audited actions with 1 safety signal', + createdAt: '2026-04-03T14:00:00.000Z', + updatedAt: '2026-04-03T14:00:00.000Z', + createdBy: { actorType: 'agent', actorId: 'phase3-audit-importer' }, + ownership: { userId: 'saravana', orgId: null }, + visibility: { + scope: 'private', + allowedProducts: ['learning_ai_notes', 'learning_multimodal_memory_agents'], + }, + status: 'recorded', + tags: ['ecosystem', 'phase3', 'audit'], + links: [], + provenance: { + originProductId: 'claw-cowork', + originActionId: 'task-123', + sessionId: 'sess_phase3', + runId: 'run_phase3_trail', + approvalId: null, + correlationId: 'corr_phase3', + lineage: [ + { + stepType: 'audit-exported', + productId: 'claw-cowork', + actorType: 'system', + timestamp: '2026-04-03T13:55:00.000Z', + }, + { + stepType: 'trail-report-created', + productId: 'actiontrail', + actorType: 'agent', + timestamp: '2026-04-03T14:00:00.000Z', + }, + ], + }, + payload: { + sourceProduct: 'claw-cowork', + sourceTaskId: 'task-123', + generatedFrom: 'audit-export-json', + reportGeneratedAt: '2026-04-03T14:00:00.000Z', + actionCount: 4, + toolCallCount: 2, + approvalCount: 1, + failureCount: 0, + safetySignalCount: 1, + tasks: ['task-123'], + actionBreakdown: [ + { action: 'TaskStarted', count: 1 }, + { action: 'ToolCall', count: 2 }, + { action: 'InjectionDetected', count: 1 }, + ], + entries: [ + { + timestamp: '2026-04-03T13:55:00.000Z', + taskId: 'task-123', + action: 'TaskStarted', + tool: null, + result: 'Success', + approval: null, + inputSummary: 'Audit seed task', + metadata: { surface: 'desktop' }, + }, + ], + }, + }); + + expect(trailReport.payload.sourceProduct).toBe('claw-cowork'); + expect(trailReport.payload.safetySignalCount).toBe(1); + }); + + it('accepts generic artifact.created events for trail-report artifacts', () => { + const created = ArtifactCreatedEventSchema.parse({ + eventId: 'evt_phase3_created', + eventName: 'artifact.created', + eventVersion: 1, + occurredAt: '2026-04-03T14:00:00.000Z', + productId: 'actiontrail', + sourceSurface: 'backend', + userId: 'saravana', + orgId: null, + sessionId: 'sess_phase3', + runId: 'run_phase3_trail', + artifactId: 'art_trail_demo', + actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, + trace: { + correlationId: 'corr_phase3', + causationId: 'evt_cowork_export', + parentEventId: 'evt_cowork_export', + }, + payload: { + artifactType: 'trail-report', + title: 'Cowork audit report for task task-123', + status: 'recorded', + }, + }); + + expect(created.payload.artifactType).toBe('trail-report'); + }); +}); diff --git a/vendor/bytelyst/events/src/ecosystem.ts b/vendor/bytelyst/events/src/ecosystem.ts new file mode 100644 index 0000000..178ec87 --- /dev/null +++ b/vendor/bytelyst/events/src/ecosystem.ts @@ -0,0 +1,349 @@ +import { z } from 'zod'; + +export const EcosystemArtifactTypeSchema = z.enum([ + 'transcript', + 'note', + 'memory', + 'plan', + 'routine', + 'habit', + 'habit-checkin', + 'trail-report', + 'route-session', + 'agent-output', + 'document', + 'digest', +]); + +export const ArtifactLinkRelationSchema = z.enum([ + 'derived-from', + 'summarizes', + 'generated-task', + 'generated-routine', + 'generated-habit', + 'generated-memory', + 'evidence-for', + 'review-of', + 'attached-to', +]); + +export const ArtifactLinkSchema = z.object({ + relation: ArtifactLinkRelationSchema, + targetArtifactId: z.string().min(1), +}); + +export const ArtifactCreatedBySchema = z.object({ + actorType: z.enum(['user', 'agent', 'system', 'mixed']), + actorId: z.string().min(1).nullable(), +}); + +export const ArtifactOwnershipSchema = z.object({ + userId: z.string().min(1), + orgId: z.string().min(1).nullable().optional(), +}); + +export const ArtifactVisibilitySchema = z.object({ + scope: z.enum(['private', 'org', 'shared', 'local-only']), + allowedProducts: z.array(z.string().min(1)).optional(), +}); + +export const ArtifactLineageStepSchema = z.object({ + stepType: z.string().min(1), + productId: z.string().min(1), + actorType: z.enum(['user', 'agent', 'system']), + timestamp: z.string().datetime(), +}); + +export const ArtifactProvenanceSchema = z.object({ + originProductId: z.string().min(1), + originActionId: z.string().min(1).nullable().optional(), + sessionId: z.string().min(1).nullable().optional(), + runId: z.string().min(1).nullable().optional(), + approvalId: z.string().min(1).nullable().optional(), + correlationId: z.string().min(1).nullable().optional(), + lineage: z.array(ArtifactLineageStepSchema).min(1), +}); + +export const BaseArtifactEnvelopeSchema = z.object({ + id: z.string().min(1), + artifactType: EcosystemArtifactTypeSchema, + schemaVersion: z.literal(1), + productId: z.string().min(1), + sourceSurface: z.string().min(1), + title: z.string().min(1).nullable(), + summary: z.string().min(1).nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + createdBy: ArtifactCreatedBySchema, + ownership: ArtifactOwnershipSchema, + visibility: ArtifactVisibilitySchema, + status: z.string().min(1), + tags: z.array(z.string().min(1)), + links: z.array(ArtifactLinkSchema), + provenance: ArtifactProvenanceSchema, + payload: z.record(z.unknown()), +}); + +export const TranscriptPayloadSchema = z.object({ + transcriptText: z.string().min(1), + transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), + language: z.string().min(1), + durationMs: z.number().int().nonnegative(), + segments: z + .array( + z.object({ + speaker: z.string().min(1).nullable().optional(), + startedAtMs: z.number().int().nonnegative(), + endedAtMs: z.number().int().nonnegative(), + text: z.string().min(1), + }) + ) + .default([]), +}); + +export const NotePayloadSchema = z.object({ + noteFormat: z.enum(['markdown', 'plain-text', 'rich-text']), + body: z.string().min(1), + excerpt: z.string().min(1).nullable().optional(), +}); + +export const MemoryPayloadSchema = z.object({ + memoryKind: z.enum(['fact', 'preference', 'person', 'project', 'insight', 'todo']), + text: z.string().min(1), + confidence: z.number().min(0).max(1), + sourceArtifactIds: z.array(z.string().min(1)).min(1), + reviewState: z.enum(['proposed', 'accepted', 'rejected']), +}); + +export const PlanPayloadSchema = z.object({ + weekOf: z.string().min(1), + taskCount: z.number().int().nonnegative(), + scheduledEntryCount: z.number().int().nonnegative(), + totalScheduledMinutes: z.number().int().nonnegative(), + entries: z.array( + z.object({ + taskTitle: z.string().min(1), + scheduledDate: z.string().min(1), + startTime: z.string().min(1), + endTime: z.string().min(1), + durationMinutes: z.number().int().nonnegative(), + flowName: z.string().min(1).nullable(), + zoneName: z.string().min(1).nullable(), + priority: z.string().min(1), + }) + ), +}); + +export const RoutinePayloadSchema = z.object({ + routineId: z.string().min(1), + stepCount: z.number().int().nonnegative(), + totalDurationMinutes: z.number().nonnegative(), + status: z.string().min(1), + isTemplate: z.boolean(), + category: z.string().min(1).nullable().optional(), + steps: z.array( + z.object({ + label: z.string().min(1), + durationMinutes: z.number().nonnegative(), + transition: z.string().min(1), + status: z.string().min(1), + }) + ), +}); + +export const HabitPayloadSchema = z.object({ + habitId: z.string().min(1), + identityId: z.string().min(1), + frequency: z.enum(['daily', 'weekly', 'custom']), + customDays: z.array(z.number().int().min(0).max(6)).optional(), + targetCount: z.number().int().positive(), + reminderTime: z.string().min(1).nullable().optional(), + isActive: z.boolean(), + sourceRoutineId: z.string().min(1), +}); + +export const TrailReportPayloadSchema = z.object({ + sourceProduct: z.literal('claw-cowork'), + sourceTaskId: z.string().min(1).nullable().optional(), + generatedFrom: z.enum(['audit-export-json', 'audit-query-json']), + reportGeneratedAt: z.string().datetime(), + actionCount: z.number().int().nonnegative(), + toolCallCount: z.number().int().nonnegative(), + approvalCount: z.number().int().nonnegative(), + failureCount: z.number().int().nonnegative(), + safetySignalCount: z.number().int().nonnegative(), + tasks: z.array(z.string().min(1)), + actionBreakdown: z.array( + z.object({ + action: z.string().min(1), + count: z.number().int().positive(), + }) + ), + entries: z.array( + z.object({ + timestamp: z.string().datetime(), + taskId: z.string().min(1).nullable(), + action: z.string().min(1), + tool: z.string().min(1).nullable().optional(), + result: z.string().min(1).nullable().optional(), + approval: z.string().min(1).nullable().optional(), + inputSummary: z.string().min(1).nullable().optional(), + metadata: z.record(z.unknown()).nullable().optional(), + }) + ), +}); + +export const TranscriptArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('transcript'), + payload: TranscriptPayloadSchema, +}); + +export const NoteArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('note'), + payload: NotePayloadSchema, +}); + +export const MemoryArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('memory'), + payload: MemoryPayloadSchema, +}); + +export const PlanArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('plan'), + payload: PlanPayloadSchema, +}); + +export const RoutineArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('routine'), + payload: RoutinePayloadSchema, +}); + +export const HabitArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('habit'), + payload: HabitPayloadSchema, +}); + +export const TrailReportArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ + artifactType: z.literal('trail-report'), + payload: TrailReportPayloadSchema, +}); + +export const Phase1ArtifactEnvelopeSchema = z.discriminatedUnion('artifactType', [ + TranscriptArtifactEnvelopeSchema, + NoteArtifactEnvelopeSchema, + MemoryArtifactEnvelopeSchema, +]); + +export const EcosystemEventActorSchema = z.object({ + actorType: z.enum(['user', 'agent', 'system', 'device']), + actorId: z.string().min(1).nullable().optional(), +}); + +export const EcosystemEventTraceSchema = z.object({ + correlationId: z.string().min(1).nullable(), + causationId: z.string().min(1).nullable(), + parentEventId: z.string().min(1).nullable(), +}); + +export const BaseEcosystemEventSchema = z.object({ + eventId: z.string().min(1), + eventName: z.string().min(1), + eventVersion: z.literal(1), + occurredAt: z.string().datetime(), + productId: z.string().min(1), + sourceSurface: z.string().min(1), + userId: z.string().min(1).nullable().optional(), + orgId: z.string().min(1).nullable().optional(), + sessionId: z.string().min(1).nullable().optional(), + runId: z.string().min(1).nullable().optional(), + artifactId: z.string().min(1).nullable().optional(), + actor: EcosystemEventActorSchema, + trace: EcosystemEventTraceSchema, + payload: z.record(z.unknown()), +}); + +export const CaptureTranscriptCreatedPayloadSchema = z.object({ + artifactId: z.string().min(1), + durationMs: z.number().int().nonnegative(), + language: z.string().min(1), + transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), +}); + +export const ArtifactCreatedPayloadSchema = z.object({ + artifactType: z.enum([ + 'transcript', + 'note', + 'memory', + 'plan', + 'routine', + 'habit', + 'trail-report', + ]), + title: z.string().min(1).nullable(), + status: z.string().min(1), +}); + +export const ArtifactLinkedPayloadSchema = z.object({ + sourceArtifactId: z.string().min(1), + targetArtifactId: z.string().min(1), + relation: z.enum([ + 'summarizes', + 'generated-memory', + 'generated-routine', + 'generated-habit', + 'derived-from', + ]), +}); + +export const MemoryEntryCreatedPayloadSchema = z.object({ + artifactId: z.string().min(1), + memoryKind: MemoryPayloadSchema.shape.memoryKind, + reviewState: MemoryPayloadSchema.shape.reviewState, + confidence: MemoryPayloadSchema.shape.confidence, + sourceArtifactIds: z.array(z.string().min(1)).min(1), +}); + +export const CaptureTranscriptCreatedEventSchema = BaseEcosystemEventSchema.extend({ + eventName: z.literal('capture.transcript.created'), + payload: CaptureTranscriptCreatedPayloadSchema, +}); + +export const ArtifactCreatedEventSchema = BaseEcosystemEventSchema.extend({ + eventName: z.literal('artifact.created'), + payload: ArtifactCreatedPayloadSchema, +}); + +export const ArtifactLinkedEventSchema = BaseEcosystemEventSchema.extend({ + eventName: z.literal('artifact.linked'), + payload: ArtifactLinkedPayloadSchema, +}); + +export const MemoryEntryCreatedEventSchema = BaseEcosystemEventSchema.extend({ + eventName: z.literal('memory.entry.created'), + payload: MemoryEntryCreatedPayloadSchema, +}); + +export const Phase1EcosystemEventSchema = z.discriminatedUnion('eventName', [ + CaptureTranscriptCreatedEventSchema, + ArtifactCreatedEventSchema, + ArtifactLinkedEventSchema, + MemoryEntryCreatedEventSchema, +]); + +export const Phase1EcosystemEventSchemas = { + 'capture.transcript.created': CaptureTranscriptCreatedEventSchema, + 'artifact.created': ArtifactCreatedEventSchema, + 'artifact.linked': ArtifactLinkedEventSchema, + 'memory.entry.created': MemoryEntryCreatedEventSchema, +} as const; + +export type ArtifactEnvelope = z.infer; +export type TranscriptArtifactEnvelope = z.infer; +export type NoteArtifactEnvelope = z.infer; +export type MemoryArtifactEnvelope = z.infer; +export type PlanArtifactEnvelope = z.infer; +export type RoutineArtifactEnvelope = z.infer; +export type HabitArtifactEnvelope = z.infer; +export type Phase1ArtifactEnvelope = z.infer; +export type EcosystemEvent = z.infer; +export type Phase1EcosystemEvent = z.infer; diff --git a/vendor/bytelyst/events/src/index.ts b/vendor/bytelyst/events/src/index.ts new file mode 100644 index 0000000..2c22a07 --- /dev/null +++ b/vendor/bytelyst/events/src/index.ts @@ -0,0 +1,80 @@ +export { EventBus } from './memory.js'; +export type { EmitResult, EmitError } from './memory.js'; +export { DurableEventBus } from './durable.js'; +export type { DurableEventBusOptions } from './durable.js'; +export { PlatformEventSchemas } from './types.js'; +export { + AgentActionLogSchema, + AgentCheckpointSchema, + AgentApprovalCheckpointSchema, + AgentDispatchContextSchema, + AgentDispatchRequestSchema, + AgentRunSchema, + AgentSessionSchema, + AgentTaskSchema, + AgentTodoSchema, +} from './agent-runtime.js'; +export { + ArtifactCreatedBySchema, + ArtifactLineageStepSchema, + ArtifactLinkRelationSchema, + ArtifactLinkSchema, + ArtifactOwnershipSchema, + ArtifactProvenanceSchema, + ArtifactVisibilitySchema, + BaseArtifactEnvelopeSchema, + BaseEcosystemEventSchema, + CaptureTranscriptCreatedEventSchema, + CaptureTranscriptCreatedPayloadSchema, + EcosystemArtifactTypeSchema, + EcosystemEventActorSchema, + EcosystemEventTraceSchema, + MemoryArtifactEnvelopeSchema, + MemoryEntryCreatedEventSchema, + MemoryEntryCreatedPayloadSchema, + MemoryPayloadSchema, + NoteArtifactEnvelopeSchema, + NotePayloadSchema, + Phase1ArtifactEnvelopeSchema, + Phase1EcosystemEventSchema, + Phase1EcosystemEventSchemas, + TranscriptArtifactEnvelopeSchema, + TranscriptPayloadSchema, + ArtifactCreatedEventSchema, + ArtifactCreatedPayloadSchema, + ArtifactLinkedEventSchema, + ArtifactLinkedPayloadSchema, +} from './ecosystem.js'; +export { + buildTimelineItem, + buildTimelineItems, + TimelineItemSchema, + TimelineVisibilitySchema, +} from './timeline.js'; +export type { + AgentActionLog, + AgentCheckpoint, + AgentApprovalCheckpoint, + AgentDispatchContext, + AgentDispatchRequest, + AgentRun, + AgentSession, + AgentTask, + AgentTodo, +} from './agent-runtime.js'; +export type { + PlatformEventName, + PlatformEventPayload, + PlatformEvent, + EventHandler, + EventSubscription, +} from './types.js'; +export type { + EcosystemEvent, + MemoryArtifactEnvelope, + NoteArtifactEnvelope, + Phase1ArtifactEnvelope, + Phase1EcosystemEvent, + TranscriptArtifactEnvelope, +} from './ecosystem.js'; +export type { TimelineItem } from './timeline.js'; diff --git a/vendor/bytelyst/events/src/memory.test.ts b/vendor/bytelyst/events/src/memory.test.ts new file mode 100644 index 0000000..87567d0 --- /dev/null +++ b/vendor/bytelyst/events/src/memory.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventBus } from './memory.js'; +import type { PlatformEvent } from './types.js'; + +describe('EventBus', () => { + let bus: EventBus; + + beforeEach(() => { + bus = new EventBus(); + }); + + describe('on / emit', () => { + it('should deliver events to registered handlers', async () => { + const handler = vi.fn(); + bus.on('user.created', handler); + + await bus.emit('user.created', { + userId: 'u1', + email: 'test@example.com', + plan: 'free', + productId: 'lysnrai', + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0]).toMatchObject({ + type: 'user.created', + payload: { userId: 'u1', email: 'test@example.com' }, + }); + }); + + it('should include event id and timestamp', async () => { + let received: PlatformEvent<'user.created'> | undefined; + bus.on('user.created', e => { + received = e; + }); + + await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + + expect(received).toBeDefined(); + expect(received!.id).toBeTruthy(); + expect(received!.timestamp).toBeTruthy(); + }); + + it('should deliver to multiple handlers', async () => { + const h1 = vi.fn(); + const h2 = vi.fn(); + const h3 = vi.fn(); + bus.on('user.created', h1); + bus.on('user.created', h2); + bus.on('user.created', h3); + + await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + + expect(h1).toHaveBeenCalledOnce(); + expect(h2).toHaveBeenCalledOnce(); + expect(h3).toHaveBeenCalledOnce(); + }); + + it('should not deliver to handlers of different event types', async () => { + const userHandler = vi.fn(); + const paymentHandler = vi.fn(); + bus.on('user.created', userHandler); + bus.on('payment.succeeded', paymentHandler); + + await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + + expect(userHandler).toHaveBeenCalledOnce(); + expect(paymentHandler).not.toHaveBeenCalled(); + }); + + it('should return result with zero handlers when no subscribers', async () => { + const result = await bus.emit('user.deleted', { + userId: 'u1', + productId: 'test', + }); + + expect(result.handlerCount).toBe(0); + expect(result.errors).toHaveLength(0); + expect(result.eventId).toBeTruthy(); + }); + }); + + describe('error isolation', () => { + it('should not block other handlers when one throws', async () => { + const h1 = vi.fn(); + const h2 = vi.fn(() => { + throw new Error('handler crash'); + }); + const h3 = vi.fn(); + + bus.on('user.created', h1); + bus.on('user.created', h2); + bus.on('user.created', h3); + + const result = await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + + expect(h1).toHaveBeenCalledOnce(); + expect(h2).toHaveBeenCalledOnce(); + expect(h3).toHaveBeenCalledOnce(); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toBe('handler crash'); + }); + + it('should handle async handler rejection', async () => { + bus.on('payment.failed', async () => { + throw new Error('async fail'); + }); + + const result = await bus.emit('payment.failed', { + invoiceId: 'inv_1', + userId: 'u1', + amount: 999, + retryCount: 1, + productId: 'test', + }); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toBe('async fail'); + }); + }); + + describe('unsubscribe', () => { + it('should stop delivering events after unsubscribe', async () => { + const handler = vi.fn(); + const sub = bus.on('user.created', handler); + + await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + expect(handler).toHaveBeenCalledOnce(); + + sub.unsubscribe(); + + await bus.emit('user.created', { + userId: 'u2', + email: 'b@c.com', + plan: 'pro', + productId: 'test', + }); + expect(handler).toHaveBeenCalledOnce(); // still 1, not 2 + }); + + it('should return subscription metadata', () => { + const sub = bus.on('flag.toggled', () => {}); + expect(sub.id).toBeTruthy(); + expect(sub.eventType).toBe('flag.toggled'); + expect(typeof sub.unsubscribe).toBe('function'); + }); + }); + + describe('clear', () => { + it('should remove all handlers for a specific event type', async () => { + const h1 = vi.fn(); + const h2 = vi.fn(); + bus.on('user.created', h1); + bus.on('payment.succeeded', h2); + + bus.clear('user.created'); + + await bus.emit('user.created', { + userId: 'u1', + email: 'a@b.com', + plan: 'free', + productId: 'test', + }); + await bus.emit('payment.succeeded', { + invoiceId: 'inv_1', + userId: 'u1', + amount: 100, + currency: 'usd', + productId: 'test', + }); + + expect(h1).not.toHaveBeenCalled(); + expect(h2).toHaveBeenCalledOnce(); + }); + + it('should remove all handlers when called without args', () => { + bus.on('user.created', () => {}); + bus.on('payment.failed', () => {}); + bus.on('flag.toggled', () => {}); + + expect(bus.eventTypes().length).toBe(3); + bus.clear(); + expect(bus.eventTypes().length).toBe(0); + }); + }); + + describe('listenerCount / eventTypes', () => { + it('should count handlers per event type', () => { + bus.on('user.created', () => {}); + bus.on('user.created', () => {}); + bus.on('payment.failed', () => {}); + + expect(bus.listenerCount('user.created')).toBe(2); + expect(bus.listenerCount('payment.failed')).toBe(1); + expect(bus.listenerCount('user.deleted')).toBe(0); + }); + + it('should list event types with handlers', () => { + bus.on('user.created', () => {}); + bus.on('payment.failed', () => {}); + + const types = bus.eventTypes(); + expect(types).toContain('user.created'); + expect(types).toContain('payment.failed'); + expect(types).not.toContain('user.deleted'); + }); + }); + + describe('source option', () => { + it('should pass source through to handlers', async () => { + let received: PlatformEvent<'user.created'> | undefined; + bus.on('user.created', e => { + received = e; + }); + + await bus.emit( + 'user.created', + { userId: 'u1', email: 'a@b.com', plan: 'free', productId: 'test' }, + { source: 'auth-module' } + ); + + expect(received?.source).toBe('auth-module'); + }); + }); +}); diff --git a/vendor/bytelyst/events/src/memory.ts b/vendor/bytelyst/events/src/memory.ts new file mode 100644 index 0000000..0c419b6 --- /dev/null +++ b/vendor/bytelyst/events/src/memory.ts @@ -0,0 +1,122 @@ +import type { + PlatformEventName, + PlatformEventPayload, + PlatformEvent, + EventHandler, + EventSubscription, +} from './types.js'; + +// ── In-Memory Event Bus ────────────────────────────────────── +// Phase 1 implementation: typed in-process pub/sub with error isolation. +// Handlers run concurrently via Promise.allSettled — a failing handler +// never blocks other handlers or the emitter. + +export class EventBus { + private handlers = new Map }>>(); + private subscriptionCounter = 0; + + /** + * Subscribe to a specific event type. + * Returns an EventSubscription with an `unsubscribe()` method. + */ + on(eventType: T, handler: EventHandler): EventSubscription { + const id = `sub_${++this.subscriptionCounter}`; + + if (!this.handlers.has(eventType)) { + this.handlers.set(eventType, new Set()); + } + + const entry = { id, fn: handler as EventHandler }; + this.handlers.get(eventType)!.add(entry); + + return { + id, + eventType, + unsubscribe: () => { + this.handlers.get(eventType)?.delete(entry); + }, + }; + } + + /** + * Emit an event to all registered handlers for that event type. + * Handlers run concurrently. Errors are collected and returned, + * never thrown — the emitter is never blocked. + */ + async emit( + eventType: T, + payload: PlatformEventPayload, + options?: { source?: string } + ): Promise { + const event: PlatformEvent = { + id: crypto.randomUUID(), + type: eventType, + payload, + timestamp: new Date().toISOString(), + source: options?.source, + }; + + const handlers = this.handlers.get(eventType); + if (!handlers || handlers.size === 0) { + return { eventId: event.id, handlerCount: 0, errors: [] }; + } + + const results = await Promise.allSettled( + Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) + ); + + const errors: EmitError[] = []; + for (const result of results) { + if (result.status === 'rejected') { + errors.push({ + eventType, + eventId: event.id, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + + return { eventId: event.id, handlerCount: handlers.size, errors }; + } + + /** + * Remove all handlers for a specific event type, or all handlers if no type given. + */ + clear(eventType?: PlatformEventName): void { + if (eventType) { + this.handlers.delete(eventType); + } else { + this.handlers.clear(); + } + } + + /** + * Get count of registered handlers for a specific event type. + */ + listenerCount(eventType: PlatformEventName): number { + return this.handlers.get(eventType)?.size ?? 0; + } + + /** + * Get all event types that have at least one handler registered. + */ + eventTypes(): PlatformEventName[] { + return Array.from(this.handlers.entries()) + .filter(([, set]) => set.size > 0) + .map(([type]) => type as PlatformEventName); + } +} + +// ── Result Types ───────────────────────────────────────────── + +export interface EmitResult { + eventId: string; + handlerCount: number; + errors: EmitError[]; +} + +export interface EmitError { + eventType: string; + eventId: string; + error: string; +} diff --git a/vendor/bytelyst/events/src/timeline.test.ts b/vendor/bytelyst/events/src/timeline.test.ts new file mode 100644 index 0000000..4f444c7 --- /dev/null +++ b/vendor/bytelyst/events/src/timeline.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { buildTimelineItems, TimelineItemSchema } from './timeline.js'; + +describe('timeline contract baseline', () => { + it('builds unified timeline items across phases 1 to 3', () => { + const items = buildTimelineItems([ + { + eventId: 'evt_capture_1', + eventName: 'capture.transcript.created', + occurredAt: '2026-04-03T12:00:00.000Z', + productId: 'lysnrai', + artifactId: 'art_transcript_1', + actor: { actorType: 'user', actorId: 'saravana' }, + trace: { correlationId: 'corr_phase1', causationId: null, parentEventId: null }, + payload: { durationMs: 42150, transcriptSource: 'microphone' }, + }, + { + eventId: 'evt_plan_1', + eventName: 'artifact.created', + occurredAt: '2026-04-03T13:00:00.000Z', + productId: 'flowmonk', + artifactId: 'art_plan_1', + actor: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, + trace: { correlationId: 'corr_phase2', causationId: null, parentEventId: null }, + payload: { artifactType: 'plan', title: 'FlowMonk weekly plan', status: 'draft' }, + }, + { + eventId: 'evt_trail_1', + eventName: 'artifact.created', + occurredAt: '2026-04-03T14:00:00.000Z', + productId: 'actiontrail', + artifactId: 'art_trail_1', + actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, + trace: { + correlationId: 'corr_phase3', + causationId: 'task_phase3', + parentEventId: 'task_phase3', + }, + payload: { + artifactType: 'trail-report', + title: 'Cowork audit report for task task_phase3', + status: 'recorded', + }, + }, + { + eventId: 'evt_memory_1', + eventName: 'memory.entry.created', + occurredAt: '2026-04-03T14:10:00.000Z', + productId: 'mindlyst', + artifactId: 'art_memory_1', + actor: { actorType: 'agent', actorId: 'phase3-audit-note-importer' }, + trace: { + correlationId: 'corr_phase3', + causationId: 'evt_note_linked_1', + parentEventId: 'evt_note_linked_1', + }, + payload: { + memoryKind: 'insight', + sourceArtifactIds: ['art_note_1', 'art_trail_1'], + }, + }, + ]); + + expect(items.map(item => item.eventName)).toEqual([ + 'memory.entry.created', + 'artifact.created', + 'artifact.created', + 'capture.transcript.created', + ]); + expect(items[0]?.title).toBe('Insight memory proposed'); + expect(items[1]?.title).toBe('Cowork audit report for task task_phase3'); + expect(items[2]?.summary).toBe('plan status: draft'); + expect(items[3]?.summary).toContain('microphone transcript captured'); + expect(items[0]?.relatedEventIds).toEqual(['evt_note_linked_1']); + }); + + it('exposes a stable timeline item schema', () => { + const item = TimelineItemSchema.parse({ + itemId: 'timeline_evt_1', + occurredAt: '2026-04-03T14:10:00.000Z', + eventName: 'memory.entry.created', + productId: 'mindlyst', + title: 'Insight memory proposed', + summary: '2 source artifacts linked', + artifactRefs: ['art_memory_1'], + relatedEventIds: ['evt_note_linked_1'], + actorType: 'agent', + visibility: 'private', + correlationId: 'corr_phase3', + }); + + expect(item.visibility).toBe('private'); + expect(item.relatedEventIds).toHaveLength(1); + }); +}); diff --git a/vendor/bytelyst/events/src/timeline.ts b/vendor/bytelyst/events/src/timeline.ts new file mode 100644 index 0000000..ad9568f --- /dev/null +++ b/vendor/bytelyst/events/src/timeline.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import type { EcosystemEvent } from './ecosystem.js'; + +export const TimelineVisibilitySchema = z.enum(['private', 'org', 'shared', 'local-only']); + +export const TimelineItemSchema = z.object({ + itemId: z.string().min(1), + occurredAt: z.string().datetime(), + eventName: z.string().min(1), + productId: z.string().min(1), + title: z.string().min(1), + summary: z.string().nullable().optional(), + artifactRefs: z.array(z.string().min(1)), + relatedEventIds: z.array(z.string().min(1)), + actorType: z.enum(['user', 'agent', 'system', 'device']), + visibility: TimelineVisibilitySchema, + correlationId: z.string().min(1).nullable().optional(), +}); + +export type TimelineItem = z.infer; + +type EventLike = Pick< + EcosystemEvent, + | 'eventId' + | 'eventName' + | 'occurredAt' + | 'productId' + | 'artifactId' + | 'actor' + | 'trace' + | 'payload' +>; + +function titleForEvent(event: EventLike): string { + if (event.eventName === 'capture.transcript.created') { + return 'Transcript captured'; + } + if (event.eventName === 'memory.entry.created') { + const kind = typeof event.payload.memoryKind === 'string' ? event.payload.memoryKind : 'memory'; + return `${capitalize(kind)} memory proposed`; + } + if (event.eventName === 'artifact.created') { + const type = + typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; + const title = + typeof event.payload.title === 'string' && event.payload.title ? event.payload.title : null; + return title ? title : `${capitalize(type)} created`; + } + if (event.eventName === 'artifact.linked') { + const relation = typeof event.payload.relation === 'string' ? event.payload.relation : 'linked'; + return `Artifact ${relation}`; + } + return event.eventName; +} + +function summaryForEvent(event: EventLike): string | null { + if (event.eventName === 'capture.transcript.created') { + const duration = + typeof event.payload.durationMs === 'number' + ? `${event.payload.durationMs}ms` + : 'unknown duration'; + const source = + typeof event.payload.transcriptSource === 'string' + ? event.payload.transcriptSource + : 'unknown source'; + return `${source} transcript captured (${duration})`; + } + if (event.eventName === 'memory.entry.created') { + const sources = Array.isArray(event.payload.sourceArtifactIds) + ? event.payload.sourceArtifactIds.length + : 0; + return `${sources} source artifact${sources === 1 ? '' : 's'} linked`; + } + if (event.eventName === 'artifact.created') { + const type = + typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; + const status = typeof event.payload.status === 'string' ? event.payload.status : 'created'; + return `${type} status: ${status}`; + } + if (event.eventName === 'artifact.linked') { + const target = + typeof event.payload.targetArtifactId === 'string' + ? event.payload.targetArtifactId + : 'unknown target'; + return `Linked to ${target}`; + } + return null; +} + +function relatedEventIdsForEvent(event: EventLike): string[] { + return Array.from( + new Set( + [event.trace.parentEventId, event.trace.causationId].filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + ) + ); +} + +export function buildTimelineItem(event: EventLike): TimelineItem { + return TimelineItemSchema.parse({ + itemId: `timeline_${event.eventId}`, + occurredAt: event.occurredAt, + eventName: event.eventName, + productId: event.productId, + title: titleForEvent(event), + summary: summaryForEvent(event), + artifactRefs: [event.artifactId].filter((value): value is string => Boolean(value)), + relatedEventIds: relatedEventIdsForEvent(event), + actorType: event.actor.actorType, + visibility: 'private', + correlationId: event.trace.correlationId ?? null, + }); +} + +export function buildTimelineItems(events: EventLike[]): TimelineItem[] { + return events + .map(buildTimelineItem) + .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt)); +} + +function capitalize(value: string): string { + return value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; +} diff --git a/vendor/bytelyst/events/src/types.ts b/vendor/bytelyst/events/src/types.ts new file mode 100644 index 0000000..f59677b --- /dev/null +++ b/vendor/bytelyst/events/src/types.ts @@ -0,0 +1,322 @@ +import { z } from 'zod'; + +// ── Platform Event Schemas ─────────────────────────────────── +// Each event type has a Zod schema for payload validation. +// Handlers receive typed payloads matching these schemas. + +export const PlatformEventSchemas = { + // Auth events + 'user.created': z.object({ + userId: z.string(), + email: z.string(), + plan: z.string(), + productId: z.string(), + }), + 'user.deleted': z.object({ + userId: z.string(), + email: z.string(), + productId: z.string(), + }), + 'user.email_verified': z.object({ + userId: z.string(), + email: z.string(), + productId: z.string(), + }), + 'user.password_reset': z.object({ + userId: z.string(), + email: z.string(), + resetToken: z.string(), + displayName: z.string().optional(), + productId: z.string(), + }), + 'user.email_verification_requested': z.object({ + userId: z.string(), + email: z.string(), + verificationToken: z.string(), + displayName: z.string().optional(), + productId: z.string(), + }), + + // SmartAuth events + 'auth.account_locked': z.object({ + userId: z.string(), + email: z.string(), + productId: z.string(), + lockedUntil: z.string(), + failedAttempts: z.number(), + }), + 'auth.oauth_linked': z.object({ + userId: z.string(), + provider: z.string(), + providerEmail: z.string(), + productId: z.string(), + }), + 'auth.oauth_unlinked': z.object({ + userId: z.string(), + provider: z.string(), + productId: z.string(), + }), + 'auth.membership_provisioned': z.object({ + userId: z.string(), + productId: z.string(), + plan: z.string(), + role: z.string(), + }), + 'auth.account_merged': z.object({ + primaryUserId: z.string(), + secondaryUserId: z.string(), + productId: z.string(), + }), + 'auth.magic_link_requested': z.object({ + userId: z.string(), + email: z.string(), + token: z.string(), + productId: z.string(), + expiresAt: z.string(), + }), + + // Subscription events + 'subscription.created': z.object({ + subscriptionId: z.string(), + userId: z.string(), + plan: z.string(), + status: z.string(), + productId: z.string(), + }), + 'subscription.changed': z.object({ + subscriptionId: z.string(), + userId: z.string(), + oldPlan: z.string(), + newPlan: z.string(), + productId: z.string(), + }), + 'subscription.canceled': z.object({ + subscriptionId: z.string(), + userId: z.string(), + reason: z.string().optional(), + productId: z.string(), + }), + 'subscription.trial_expiring': z.object({ + subscriptionId: z.string(), + userId: z.string(), + expiresAt: z.string(), + productId: z.string(), + }), + 'subscription.trial_expired': z.object({ + subscriptionId: z.string(), + userId: z.string(), + productId: z.string(), + }), + + // Payment events + 'payment.succeeded': z.object({ + invoiceId: z.string(), + userId: z.string(), + amount: z.number(), + currency: z.string(), + productId: z.string(), + }), + 'payment.failed': z.object({ + invoiceId: z.string(), + userId: z.string(), + amount: z.number(), + currency: z.string().default('usd'), + retryCount: z.number(), + productId: z.string(), + }), + + // Growth events + 'invitation.redeemed': z.object({ + invitationId: z.string(), + userId: z.string(), + productId: z.string(), + }), + 'referral.completed': z.object({ + referralId: z.string(), + referrerId: z.string(), + referredId: z.string(), + productId: z.string(), + }), + 'waitlist.joined': z.object({ + email: z.string(), + position: z.number(), + productId: z.string(), + }), + + // Feature flags + 'flag.toggled': z.object({ + flagId: z.string(), + enabled: z.boolean(), + percentage: z.number().optional(), + productId: z.string(), + flagKey: z.string().optional(), + actor: z.string().optional(), + }), + 'flag.created': z.object({ + productId: z.string(), + flagKey: z.string(), + actor: z.string().optional(), + }), + 'flag.updated': z.object({ + productId: z.string(), + flagKey: z.string(), + actor: z.string().optional(), + changes: z.array(z.string()).optional(), + }), + 'flag.deleted': z.object({ + productId: z.string(), + flagKey: z.string(), + actor: z.string().optional(), + }), + 'flag.kill_switch': z.object({ + productId: z.string(), + disabled: z.array(z.string()), + actor: z.string().optional(), + }), + + // License events + 'license.activated': z.object({ + licenseId: z.string(), + userId: z.string(), + deviceId: z.string().optional(), + productId: z.string(), + }), + 'license.expired': z.object({ + licenseId: z.string(), + userId: z.string(), + productId: z.string(), + }), + + // Job events + 'job.completed': z.object({ + jobName: z.string(), + runId: z.string(), + durationMs: z.number(), + productId: z.string(), + }), + 'job.failed': z.object({ + jobName: z.string(), + runId: z.string(), + error: z.string(), + productId: z.string(), + }), + + // Diagnostics events + 'diagnostics.session.created': z.object({ + sessionId: z.string(), + productId: z.string(), + targetUserId: z.string().optional(), + targetAnonymousId: z.string().optional(), + targetDeviceId: z.string().optional(), + createdBy: z.string(), + }), + 'diagnostics.session.started': z.object({ + sessionId: z.string(), + productId: z.string(), + startedAt: z.string(), + }), + 'diagnostics.session.updated': z.object({ + sessionId: z.string(), + productId: z.string(), + changes: z.record(z.unknown()), + updatedBy: z.string(), + }), + 'diagnostics.session.cancelled': z.object({ + sessionId: z.string(), + productId: z.string(), + reason: z.string().optional(), + cancelledBy: z.string(), + }), + 'diagnostics.session.completed': z.object({ + sessionId: z.string(), + productId: z.string(), + stats: z.object({ + logCount: z.number(), + traceCount: z.number(), + screenshotCount: z.number(), + }), + endedAt: z.string(), + }), + 'diagnostics.session.expired': z.object({ + sessionId: z.string(), + productId: z.string(), + expiredAt: z.string(), + }), + 'diagnostics.ingest.fatal': z.object({ + sessionId: z.string(), + productId: z.string(), + logEntry: z.record(z.unknown()).optional(), + timestamp: z.string(), + }), + 'diagnostics.screenshot.captured': z.object({ + sessionId: z.string(), + productId: z.string(), + screenshotId: z.string(), + trigger: z.enum(['manual', 'error', 'interval', 'user_request']), + }), + + // Delivery events + 'delivery.email.requested': z.object({ + userId: z.string(), + productId: z.string(), + templateId: z.string(), + context: z.record(z.unknown()).optional(), + }), + + // Notifications events + 'notifications.push.requested': z.object({ + userId: z.string(), + productId: z.string(), + title: z.string(), + body: z.string(), + data: z.record(z.unknown()).optional(), + }), + 'notifications.inapp.create': z.object({ + userId: z.string(), + productId: z.string(), + title: z.string(), + content: z.string(), + priority: z.enum(['high', 'normal', 'low']).optional(), + }), + + // Integration events + 'integrations.slack.notify': z.object({ + channel: z.string(), + message: z.record(z.unknown()), + }), + + // Predictive analytics events + 'predictive.campaign.triggered': z.object({ + campaignId: z.string(), + userId: z.string(), + productId: z.string(), + riskSegment: z.string(), + channels: z.array(z.string()), + }), +} as const; + +// ── Derived Types ──────────────────────────────────────────── + +export type PlatformEventName = keyof typeof PlatformEventSchemas; + +export type PlatformEventPayload = z.infer< + (typeof PlatformEventSchemas)[T] +>; + +export interface PlatformEvent { + id: string; + type: T; + payload: PlatformEventPayload; + timestamp: string; + source?: string; +} + +export type EventHandler = ( + event: PlatformEvent +) => void | Promise; + +export interface EventSubscription { + id: string; + eventType: PlatformEventName; + unsubscribe: () => void; +} diff --git a/vendor/bytelyst/events/tsconfig.json b/vendor/bytelyst/events/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/events/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/extraction/package.json b/vendor/bytelyst/extraction/package.json new file mode 100644 index 0000000..f83be5c --- /dev/null +++ b/vendor/bytelyst/extraction/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/extraction", + "version": "0.1.5", + "type": "module", + "description": "Shared types and client for the extraction 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 --pool forks" + }, + "peerDependencies": { + "@bytelyst/api-client": "workspace:*" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts b/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts new file mode 100644 index 0000000..5c15c98 --- /dev/null +++ b/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts @@ -0,0 +1,323 @@ +/** + * Tests for @bytelyst/extraction package — client factory + types. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createExtractionClient } from '../client.js'; +import type { + ExtractRequest, + ExtractResponse, + BatchExtractRequest, + BatchExtractResponse, + ExtractionTask, + ExtractionClientConfig, + ExtractionEntity, + ExtractionExample, +} from '../types.js'; + +// ── Mock @bytelyst/api-client ────────────────────────────────── + +const mockApiFetch = vi.fn(); + +vi.mock('@bytelyst/api-client', () => ({ + createApiClient: vi.fn(() => ({ + fetch: mockApiFetch, + })), +})); + +describe('createExtractionClient', () => { + beforeEach(() => { + mockApiFetch.mockReset(); + }); + + it('returns an object with extract, extractBatch, listTasks, getTask', () => { + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + expect(typeof client.extract).toBe('function'); + expect(typeof client.extractBatch).toBe('function'); + expect(typeof client.listTasks).toBe('function'); + expect(typeof client.getTask).toBe('function'); + }); + + describe('extract', () => { + it('calls POST /api/extract with correct body', async () => { + const mockResponse: ExtractResponse = { + extractions: [{ extraction_class: 'person', extraction_text: 'John' }], + metadata: { modelId: 'gemini-1.5', durationMs: 150, charCount: 35 }, + }; + mockApiFetch.mockResolvedValue(mockResponse); + + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + const req: ExtractRequest = { + text: 'John said we should ship by Friday.', + taskId: 'transcript-extraction', + }; + + const result = await client.extract(req); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { + method: 'POST', + body: JSON.stringify(req), + }); + expect(result).toEqual(mockResponse); + }); + + it('passes optional fields in request', async () => { + mockApiFetch.mockResolvedValue({ extractions: [], metadata: {} }); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + const req: ExtractRequest = { + text: 'Hello world', + taskPrompt: 'Extract entities', + modelId: 'gpt-4', + productId: 'lysnrai', + options: { extractionPasses: 2, maxWorkers: 4, maxCharBuffer: 1000 }, + examples: [ + { text: 'Hi Bob', extractions: [{ extraction_class: 'person', extraction_text: 'Bob' }] }, + ], + }; + + await client.extract(req); + expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { + method: 'POST', + body: JSON.stringify(req), + }); + }); + + it('propagates errors from api client', async () => { + mockApiFetch.mockRejectedValue(new Error('Forbidden')); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await expect(client.extract({ text: 'test' })).rejects.toThrow('Forbidden'); + }); + }); + + describe('extractBatch', () => { + it('calls POST /api/extract/batch with correct body', async () => { + const mockResponse: BatchExtractResponse = { + results: [{ extractions: [], metadata: { modelId: 'test', durationMs: 10, charCount: 5 } }], + requestId: 'req-123', + }; + mockApiFetch.mockResolvedValue(mockResponse); + + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + const req: BatchExtractRequest = { + inputs: [ + { text: 'First document', taskId: 'triage' }, + { text: 'Second document', taskPrompt: 'Extract names' }, + ], + modelId: 'gemini-1.5', + productId: 'mindlyst', + }; + + const result = await client.extractBatch(req); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/extract/batch', { + method: 'POST', + body: JSON.stringify(req), + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('listTasks', () => { + it('calls GET /api/tasks without productId', async () => { + const tasks: ExtractionTask[] = [ + { + id: 'triage', + name: 'Triage Extraction', + prompt: 'Extract entities', + classes: ['person', 'date'], + builtIn: true, + productId: 'lysnrai', + }, + ]; + mockApiFetch.mockResolvedValue(tasks); + + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + const result = await client.listTasks(); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks'); + expect(result).toEqual(tasks); + }); + + it('appends productId query param when provided', async () => { + mockApiFetch.mockResolvedValue([]); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await client.listTasks('mindlyst'); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=mindlyst'); + }); + + it('encodes special characters in productId', async () => { + mockApiFetch.mockResolvedValue([]); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await client.listTasks('my product'); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=my%20product'); + }); + }); + + describe('getTask', () => { + it('calls GET /api/tasks/:id without productId', async () => { + const task: ExtractionTask = { + id: 'triage', + name: 'Triage', + prompt: 'Extract', + classes: ['person'], + builtIn: true, + productId: 'lysnrai', + }; + mockApiFetch.mockResolvedValue(task); + + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + const result = await client.getTask('triage'); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/triage'); + expect(result).toEqual(task); + }); + + it('appends productId query param when provided', async () => { + mockApiFetch.mockResolvedValue({}); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await client.getTask('my-task', 'mindlyst'); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/my-task?productId=mindlyst'); + }); + + it('encodes special characters in task id', async () => { + mockApiFetch.mockResolvedValue({}); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await client.getTask('task with spaces'); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/task%20with%20spaces'); + }); + }); + + describe('transcribe', () => { + it('calls POST /api/transcribe with correct body', async () => { + const mockResponse = { + text: 'Hello, this is a test recording.', + language: 'en', + durationSeconds: 5.2, + model: 'whisper-1', + durationMs: 1200, + }; + mockApiFetch.mockResolvedValue(mockResponse); + + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + const req = { + audioUrl: 'https://blob.example.com/audio.mp3', + language: 'en', + productId: 'notelett', + }; + + const result = await client.transcribe(req); + + expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { + method: 'POST', + body: JSON.stringify(req), + }); + expect(result.text).toBe('Hello, this is a test recording.'); + expect(result.language).toBe('en'); + expect(result.durationSeconds).toBe(5.2); + }); + + it('passes optional model and prompt fields', async () => { + mockApiFetch.mockResolvedValue({ + text: 'test', + language: null, + durationSeconds: null, + model: 'whisper-1', + durationMs: 100, + }); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + const req = { + audioUrl: 'https://blob.example.com/meeting.wav', + model: 'whisper-1', + prompt: 'Technical meeting about software architecture.', + responseFormat: 'verbose_json' as const, + }; + + await client.transcribe(req); + expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { + method: 'POST', + body: JSON.stringify(req), + }); + }); + + it('propagates errors from api client', async () => { + mockApiFetch.mockRejectedValue(new Error('Service unavailable')); + const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + await expect( + client.transcribe({ audioUrl: 'https://blob.example.com/audio.mp3' }) + ).rejects.toThrow('Service unavailable'); + }); + }); + + describe('config options', () => { + it('passes getToken to createApiClient', async () => { + const { createApiClient } = await import('@bytelyst/api-client'); + const getToken = () => 'my-token'; + + createExtractionClient({ baseUrl: 'http://localhost:4005', getToken }); + + expect(createApiClient).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:4005', + getToken, + }); + }); + + it('works without getToken', async () => { + const { createApiClient } = await import('@bytelyst/api-client'); + + createExtractionClient({ baseUrl: 'http://localhost:4005' }); + + expect(createApiClient).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:4005', + getToken: undefined, + }); + }); + }); +}); + +describe('Extraction types', () => { + it('ExtractionEntity shape is correct', () => { + const entity: ExtractionEntity = { + extraction_class: 'person', + extraction_text: 'John Doe', + attributes: { role: 'engineer' }, + start_offset: 0, + end_offset: 8, + }; + expect(entity.extraction_class).toBe('person'); + expect(entity.attributes?.role).toBe('engineer'); + }); + + it('ExtractionExample shape is correct', () => { + const example: ExtractionExample = { + text: 'Meet Bob at 3pm', + extractions: [ + { extraction_class: 'person', extraction_text: 'Bob' }, + { extraction_class: 'time', extraction_text: '3pm' }, + ], + }; + expect(example.extractions).toHaveLength(2); + }); + + it('ExtractionClientConfig with optional getToken', () => { + const config1: ExtractionClientConfig = { baseUrl: 'http://localhost:4005' }; + expect(config1.getToken).toBeUndefined(); + + const config2: ExtractionClientConfig = { + baseUrl: 'http://localhost:4005', + getToken: () => 'tok', + }; + expect(config2.getToken?.()).toBe('tok'); + }); +}); diff --git a/vendor/bytelyst/extraction/src/client.ts b/vendor/bytelyst/extraction/src/client.ts new file mode 100644 index 0000000..7c105ad --- /dev/null +++ b/vendor/bytelyst/extraction/src/client.ts @@ -0,0 +1,90 @@ +/** + * Extraction service client factory. + * Uses @bytelyst/api-client under the hood for consistent auth token injection. + */ + +import { createApiClient } from '@bytelyst/api-client'; + +import type { + ExtractionClientConfig, + ExtractRequest, + ExtractResponse, + BatchExtractRequest, + BatchExtractResponse, + ExtractionTask, + TranscribeRequest, + TranscribeResponse, +} from './types.js'; + +export interface ExtractionClient { + /** Single document extraction. */ + extract(req: ExtractRequest): Promise; + + /** Batch extraction (multiple inputs, shared config). */ + extractBatch(req: BatchExtractRequest): Promise; + + /** List available extraction tasks. */ + listTasks(productId?: string): Promise; + + /** Get a single task by ID. */ + getTask(id: string, productId?: string): Promise; + + /** Transcribe audio from a URL via the configured STT provider. */ + transcribe(req: TranscribeRequest): Promise; +} + +/** + * Create a typed extraction service client. + * + * @example + * ```ts + * const client = createExtractionClient({ + * baseUrl: "http://localhost:4005", + * getToken: () => localStorage.getItem("access_token"), + * }); + * + * const result = await client.extract({ + * text: "John said we should ship by Friday.", + * taskId: "transcript-extraction", + * }); + * ``` + */ +export function createExtractionClient(config: ExtractionClientConfig): ExtractionClient { + const api = createApiClient({ + baseUrl: config.baseUrl, + getToken: config.getToken, + }); + + return { + async extract(req: ExtractRequest): Promise { + return api.fetch('/api/extract', { + method: 'POST', + body: JSON.stringify(req), + }); + }, + + async extractBatch(req: BatchExtractRequest): Promise { + return api.fetch('/api/extract/batch', { + method: 'POST', + body: JSON.stringify(req), + }); + }, + + async listTasks(productId?: string): Promise { + const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; + return api.fetch(`/api/tasks${qs}`); + }, + + async getTask(id: string, productId?: string): Promise { + const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; + return api.fetch(`/api/tasks/${encodeURIComponent(id)}${qs}`); + }, + + async transcribe(req: TranscribeRequest): Promise { + return api.fetch('/api/transcribe', { + method: 'POST', + body: JSON.stringify(req), + }); + }, + }; +} diff --git a/vendor/bytelyst/extraction/src/index.ts b/vendor/bytelyst/extraction/src/index.ts new file mode 100644 index 0000000..e8dbffb --- /dev/null +++ b/vendor/bytelyst/extraction/src/index.ts @@ -0,0 +1,14 @@ +export { createExtractionClient } from './client.js'; +export type { ExtractionClient } from './client.js'; +export type { + ExtractionEntity, + ExtractionExample, + ExtractionTask, + ExtractRequest, + ExtractResponse, + BatchExtractRequest, + BatchExtractResponse, + TranscribeRequest, + TranscribeResponse, + ExtractionClientConfig, +} from './types.js'; diff --git a/vendor/bytelyst/extraction/src/types.ts b/vendor/bytelyst/extraction/src/types.ts new file mode 100644 index 0000000..a3fbab3 --- /dev/null +++ b/vendor/bytelyst/extraction/src/types.ts @@ -0,0 +1,110 @@ +// ── Extraction types (shared across consumers) ───────────────── + +export interface ExtractionEntity { + extraction_class: string; + extraction_text: string; + attributes?: Record; + start_offset?: number; + end_offset?: number; +} + +export interface ExtractionExample { + text: string; + extractions: ExtractionEntity[]; +} + +export interface ExtractionTask { + id: string; + name: string; + description?: string; + prompt: string; + classes: string[]; + examples?: ExtractionExample[]; + defaultModelId?: string; + builtIn: boolean; + productId: string; + createdAt?: string; + updatedAt?: string; +} + +// ── Request / Response types ──────────────────────────────────── + +export interface ExtractRequest { + text: string; + taskId?: string; + taskPrompt?: string; + examples?: ExtractionExample[]; + modelId?: string; + options?: { + extractionPasses?: number; + maxWorkers?: number; + maxCharBuffer?: number; + }; + productId?: string; +} + +export interface ExtractResponse { + extractions: ExtractionEntity[]; + metadata: { + modelId: string; + durationMs: number; + tokenCount?: number; + charCount: number; + }; + requestId?: string; +} + +export interface BatchExtractRequest { + inputs: Array<{ + text: string; + taskId?: string; + taskPrompt?: string; + }>; + examples?: ExtractionExample[]; + modelId?: string; + productId?: string; +} + +export interface BatchExtractResponse { + results: ExtractResponse[]; + requestId?: string; +} + +// ── Transcription types ───────────────────────────────────────── + +export interface TranscribeRequest { + /** URL of the audio file (e.g. Azure Blob SAS URL). */ + audioUrl: string; + /** Override the Whisper model (default: whisper-1). */ + model?: string; + /** ISO 639-1 language hint (e.g. 'en', 'es'). Improves accuracy. */ + language?: string; + /** Optional prompt to guide the transcription style. */ + prompt?: string; + /** Response format: 'text' | 'json' | 'verbose_json'. */ + responseFormat?: 'text' | 'json' | 'verbose_json'; + /** Product ID for scoping / rate limiting. */ + productId?: string; +} + +export interface TranscribeResponse { + /** The transcribed text. */ + text: string; + /** Detected or specified language code. */ + language: string | null; + /** Duration of the audio in seconds (when available). */ + durationSeconds: number | null; + /** Whisper model used. */ + model: string; + /** Processing time in milliseconds. */ + durationMs: number; + /** Request ID for tracing. */ + requestId?: string; +} + +// ── Client config ─────────────────────────────────────────────── + +export interface ExtractionClientConfig { + baseUrl: string; + getToken?: () => string | null; +} diff --git a/vendor/bytelyst/extraction/tsconfig.json b/vendor/bytelyst/extraction/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/extraction/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/vendor/bytelyst/fastify-auth/package.json b/vendor/bytelyst/fastify-auth/package.json new file mode 100644 index 0000000..59edb9b --- /dev/null +++ b/vendor/bytelyst/fastify-auth/package.json @@ -0,0 +1,39 @@ +{ + "name": "@bytelyst/fastify-auth", + "version": "0.1.5", + "description": "JWT auth middleware + request context for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run --pool forks", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "fastify": ">=5.0.0", + "jose": ">=5.0.0" + }, + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "devDependencies": { + "fastify": "^5.2.1", + "jose": "^6.0.8", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/fastify-auth/src/auth.ts b/vendor/bytelyst/fastify-auth/src/auth.ts new file mode 100644 index 0000000..9828931 --- /dev/null +++ b/vendor/bytelyst/fastify-auth/src/auth.ts @@ -0,0 +1,88 @@ +/** + * Configurable JWT auth middleware — RS256 JWKS verification with HS256 fallback. + * + * Factory function creates extractAuth() and requireRole() bound to the + * provided config, eliminating the need for each product backend to maintain + * its own copy. + */ +import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; +import type { AuthPayload, FastifyAuthOptions } from './types.js'; + +export function createAuthMiddleware(opts: FastifyAuthOptions) { + // Lazy-init JWKS client (cached, auto-refreshed by jose) + let jwks: ReturnType | null = null; + let cachedJwksUrl: string | undefined; + + function resolveJwksUrl(): string | undefined { + return typeof opts.jwksUrl === 'function' ? opts.jwksUrl() : opts.jwksUrl; + } + + function resolveJwtSecret(): string { + return typeof opts.jwtSecret === 'function' ? opts.jwtSecret() : opts.jwtSecret; + } + + function getJWKS(): ReturnType | null { + const url = resolveJwksUrl(); + if (!url) return null; + if (jwks && cachedJwksUrl === url) return jwks; + jwks = createRemoteJWKSet(new URL(url)); + cachedJwksUrl = url; + return jwks; + } + + function getHmacSecret(): Uint8Array { + return new TextEncoder().encode(resolveJwtSecret()); + } + + /** + * Extract and verify auth payload from an Authorization header. + * Tries RS256 via JWKS first, falls back to HS256. + */ + async function extractAuth(req: { headers: { authorization?: string } }): Promise { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + throw new UnauthorizedError(); + } + const token = auth.slice(7); + + // Try RS256 via JWKS first + const remoteJWKS = getJWKS(); + if (remoteJWKS) { + try { + const { payload } = await jwtVerify(token, remoteJWKS); + const p = payload as unknown as AuthPayload; + if (p.type !== 'access') throw new Error('Not an access token'); + return p; + } catch { + // Fall through to HS256 + } + } + + // Fall back to HS256 (existing behavior) + try { + const { payload } = await jwtVerify(token, getHmacSecret()); + const p = payload as unknown as AuthPayload; + if (p.type !== 'access') throw new Error('Not an access token'); + return p; + } catch { + throw new UnauthorizedError('Invalid or expired token'); + } + } + + /** + * Require specific roles. Extracts auth first, then checks role. + */ + async function requireRole( + req: { headers: { authorization?: string } }, + ...roles: string[] + ): Promise { + const payload = await extractAuth(req); + if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { + throw new ForbiddenError('Insufficient permissions'); + } + return payload; + } + + return { extractAuth, requireRole }; +} diff --git a/vendor/bytelyst/fastify-auth/src/index.test.ts b/vendor/bytelyst/fastify-auth/src/index.test.ts new file mode 100644 index 0000000..23a0f11 --- /dev/null +++ b/vendor/bytelyst/fastify-auth/src/index.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { SignJWT } from 'jose'; +import { createAuthMiddleware, createRequestContext } from './index.js'; +import type { JwtPayload } from './types.js'; + +const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package'; + +interface MockReq { + headers: Record; + jwtPayload?: JwtPayload; +} + +function makeReq(token?: string): MockReq { + return { + headers: { + authorization: token ? `Bearer ${token}` : undefined, + }, + jwtPayload: undefined, + }; +} + +async function signToken(payload: Record, secret = TEST_SECRET) { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode(secret)); +} + +describe('createAuthMiddleware', () => { + const { extractAuth, requireRole } = createAuthMiddleware({ + jwtSecret: TEST_SECRET, + }); + + it('extracts auth payload from valid access token', async () => { + const token = await signToken({ + sub: 'user-1', + email: 'test@test.com', + role: 'admin', + type: 'access', + }); + const payload = await extractAuth(makeReq(token)); + expect(payload.sub).toBe('user-1'); + expect(payload.email).toBe('test@test.com'); + expect(payload.role).toBe('admin'); + }); + + it('throws UnauthorizedError when no Authorization header', async () => { + await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized'); + }); + + it('throws UnauthorizedError for invalid token', async () => { + await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token'); + }); + + it('throws UnauthorizedError for non-access token type', async () => { + const token = await signToken({ sub: 'u1', type: 'refresh' }); + await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); + }); + + it('throws UnauthorizedError for wrong secret', async () => { + const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong'); + await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); + }); + + it('requireRole passes when role matches', async () => { + const token = await signToken({ + sub: 'u1', + role: 'admin', + type: 'access', + }); + const payload = await requireRole(makeReq(token), 'admin', 'superadmin'); + expect(payload.sub).toBe('u1'); + }); + + it('requireRole throws ForbiddenError when role does not match', async () => { + const token = await signToken({ + sub: 'u1', + role: 'viewer', + type: 'access', + }); + await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions'); + }); + + it('requireRole passes with no required roles (any authenticated user)', async () => { + const token = await signToken({ + sub: 'u1', + type: 'access', + }); + const payload = await requireRole(makeReq(token)); + expect(payload.sub).toBe('u1'); + }); +}); + +describe('createRequestContext', () => { + const { getRequestProductId, getUserId } = createRequestContext({ + productId: 'testproduct', + }); + + function makeFastifyReq(overrides?: { + jwtPayload?: JwtPayload; + headers?: Record; + }) { + const req = makeReq(); + if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload; + if (overrides?.headers) Object.assign(req.headers, overrides.headers); + return req as unknown as import('fastify').FastifyRequest; + } + + it('returns product ID for valid request', () => { + expect(getRequestProductId(makeFastifyReq())).toBe('testproduct'); + }); + + it('returns product ID when JWT productId matches', () => { + expect( + getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } })) + ).toBe('testproduct'); + }); + + it('throws BadRequestError when JWT productId does not match', () => { + expect(() => + getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } })) + ).toThrow('Invalid productId'); + }); + + it('throws BadRequestError when X-Product-Id header does not match', () => { + expect(() => + getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } })) + ).toThrow('Invalid productId'); + }); + + it('getUserId returns sub from JWT payload', () => { + expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42'); + }); + + it('getUserId throws when no JWT payload', () => { + expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId'); + }); + + it('getUserId throws when JWT has no sub', () => { + expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow( + 'Missing userId' + ); + }); +}); diff --git a/vendor/bytelyst/fastify-auth/src/index.ts b/vendor/bytelyst/fastify-auth/src/index.ts new file mode 100644 index 0000000..903ec4c --- /dev/null +++ b/vendor/bytelyst/fastify-auth/src/index.ts @@ -0,0 +1,8 @@ +export { createAuthMiddleware } from './auth.js'; +export { createRequestContext } from './request-context.js'; +export type { + AuthPayload, + JwtPayload, + FastifyAuthOptions, + RequestContextOptions, +} from './types.js'; diff --git a/vendor/bytelyst/fastify-auth/src/request-context.ts b/vendor/bytelyst/fastify-auth/src/request-context.ts new file mode 100644 index 0000000..27eda5e --- /dev/null +++ b/vendor/bytelyst/fastify-auth/src/request-context.ts @@ -0,0 +1,47 @@ +/** + * Configurable request context helpers for Fastify product backends. + * + * Factory function creates getRequestProductId() and getUserId() bound to + * the provided product ID, eliminating hardcoded product IDs in each repo. + */ +import type { FastifyRequest } from 'fastify'; +import { BadRequestError } from '@bytelyst/errors'; +import type { RequestContextOptions } from './types.js'; + +export function createRequestContext(opts: RequestContextOptions) { + const { productId } = opts; + + /** + * Extract productId from request. Validates against this backend's product ID. + * Falls back to the configured productId since this is a product-specific backend. + */ + function getRequestProductId(req: FastifyRequest): string { + // 1. From JWT + const jwtPid = req.jwtPayload?.productId; + if (jwtPid && jwtPid !== productId) { + throw new BadRequestError(`Invalid productId: expected ${productId}, got ${jwtPid}`); + } + + // 2. From header + const header = req.headers['x-product-id']; + if (typeof header === 'string' && header.length > 0 && header !== productId) { + throw new BadRequestError(`Invalid productId: expected ${productId}, got ${header}`); + } + + return productId; + } + + /** + * Extract userId from the JWT payload on the request. + * Throws BadRequestError if no authenticated user is found. + */ + function getUserId(req: FastifyRequest): string { + const sub = req.jwtPayload?.sub; + if (!sub) { + throw new BadRequestError('Missing userId — request must be authenticated'); + } + return sub; + } + + return { getRequestProductId, getUserId }; +} diff --git a/vendor/bytelyst/fastify-auth/src/types.ts b/vendor/bytelyst/fastify-auth/src/types.ts new file mode 100644 index 0000000..32dd973 --- /dev/null +++ b/vendor/bytelyst/fastify-auth/src/types.ts @@ -0,0 +1,44 @@ +/** + * JWT payload shape expected from platform-service tokens. + * Re-exported from @bytelyst/auth for convenience. + */ +export interface AuthPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; + iat?: number; + exp?: number; + iss?: string; +} + +/** JWT payload shape attached to req by the onRequest hook. */ +export interface JwtPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +} + +/** Options for creating the auth middleware. */ +export interface FastifyAuthOptions { + /** HS256 symmetric secret for JWT verification. May be a getter for dynamic config. */ + jwtSecret: string | (() => string); + /** Optional RS256 JWKS endpoint URL (tried first, falls back to HS256). May be a getter. */ + jwksUrl?: string | (() => string | undefined); +} + +/** Options for creating the request context helpers. */ +export interface RequestContextOptions { + /** The product ID this backend serves (e.g. 'peakpulse', 'nomgap'). */ + productId: string; +} + +// Augment Fastify request to include parsed JWT payload +declare module 'fastify' { + interface FastifyRequest { + jwtPayload?: JwtPayload; + } +} diff --git a/vendor/bytelyst/fastify-auth/tsconfig.json b/vendor/bytelyst/fastify-auth/tsconfig.json new file mode 100644 index 0000000..01c4d9a --- /dev/null +++ b/vendor/bytelyst/fastify-auth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/fastify-core/package.json b/vendor/bytelyst/fastify-core/package.json new file mode 100644 index 0000000..20a6dfc --- /dev/null +++ b/vendor/bytelyst/fastify-core/package.json @@ -0,0 +1,45 @@ +{ + "name": "@bytelyst/fastify-core", + "version": "0.1.5", + "type": "module", + "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 --pool forks" + }, + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "peerDependencies": { + "@fastify/cors": ">=10.0.0", + "@fastify/swagger": ">=9.0.0", + "fastify": ">=5.0.0", + "fastify-metrics": ">=10.0.0" + }, + "peerDependenciesMeta": { + "@fastify/swagger": { + "optional": true + }, + "fastify-metrics": { + "optional": true + } + }, + "devDependencies": { + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", + "fastify-metrics": "^10.6.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts b/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts new file mode 100644 index 0000000..77e6233 --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createServiceApp, registerOptionalJwtContext, startService } from '../index.js'; + +type JwtRequest = { jwtPayload?: unknown }; + +describe('createServiceApp', () => { + it('returns a Fastify instance', async () => { + const app = await createServiceApp({ + name: 'test-service', + version: '0.0.1', + logger: false, + }); + expect(app).toBeDefined(); + expect(typeof app.listen).toBe('function'); + expect(typeof app.get).toBe('function'); + await app.close(); + }); + + it('has a /health endpoint returning correct shape', async () => { + const app = await createServiceApp({ + name: 'my-service', + version: '1.2.3', + description: 'A test service', + logger: false, + }); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.payload); + expect(body.status).toBe('ok'); + expect(body.service).toBe('my-service'); + expect(body.version).toBe('1.2.3'); + expect(body.description).toBe('A test service'); + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(body.requestId).toBe(res.headers['x-request-id']); + + await app.close(); + }); + + it('propagates x-request-id header', async () => { + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + const customId = 'req-12345'; + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { 'x-request-id': customId }, + }); + + expect(res.headers['x-request-id']).toBe(customId); + const body = JSON.parse(res.payload); + expect(body.requestId).toBe(customId); + + await app.close(); + }); + + it('generates x-request-id when not provided', async () => { + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + const res = await app.inject({ method: 'GET', url: '/health' }); + expect(res.headers['x-request-id']).toBeTruthy(); + // Should be UUID format + expect(res.headers['x-request-id']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + const body = JSON.parse(res.payload); + expect(body.requestId).toBe(res.headers['x-request-id']); + + await app.close(); + }); + + it('handles ServiceError with correct status code', async () => { + const { NotFoundError } = await import('@bytelyst/errors'); + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + app.get('/fail', async () => { + throw new NotFoundError('User not found'); + }); + + const res = await app.inject({ method: 'GET', url: '/fail' }); + expect(res.statusCode).toBe(404); + const body = JSON.parse(res.payload); + expect(body.error).toBe('User not found'); + expect(body.requestId).toBe(res.headers['x-request-id']); + + await app.close(); + }); + + it('handles ServiceError with details', async () => { + const { BadRequestError } = await import('@bytelyst/errors'); + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + app.get('/bad', async () => { + throw new BadRequestError('Validation failed', { field: 'email' }); + }); + + const res = await app.inject({ method: 'GET', url: '/bad' }); + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.payload); + expect(body.error).toBe('Validation failed'); + expect(body.details).toEqual({ field: 'email' }); + expect(body.requestId).toBe(res.headers['x-request-id']); + + await app.close(); + }); + + it('handles unknown errors as 500', async () => { + const app = await createServiceApp({ + name: 'test', + version: '0.1.0', + logger: false, + }); + + app.get('/crash', async () => { + throw new Error('unexpected'); + }); + + const res = await app.inject({ method: 'GET', url: '/crash' }); + expect(res.statusCode).toBe(500); + const body = JSON.parse(res.payload); + expect(body.error).toBe('Internal server error'); + expect(body.requestId).toBe(res.headers['x-request-id']); + + await app.close(); + }); + + it('omits description from health when not provided', async () => { + const app = await createServiceApp({ + name: 'minimal', + version: '0.1.0', + logger: false, + }); + + const res = await app.inject({ method: 'GET', url: '/health' }); + const body = JSON.parse(res.payload); + expect(body.description).toBeUndefined(); + + await app.close(); + }); + + it('supports readiness endpoint and returns not_ready until enabled', async () => { + const app = await createServiceApp({ + name: 'ready-test', + version: '1.0.0', + readiness: true, + logger: false, + }); + + const before = await app.inject({ method: 'GET', url: '/ready' }); + expect(before.statusCode).toBe(503); + expect(JSON.parse(before.payload)).toMatchObject({ + status: 'not_ready', + service: 'ready-test', + version: '1.0.0', + requestId: before.headers['x-request-id'], + }); + + app.setReadyState(true); + + const after = await app.inject({ method: 'GET', url: '/ready' }); + expect(after.statusCode).toBe(200); + expect(JSON.parse(after.payload)).toMatchObject({ + status: 'ready', + service: 'ready-test', + version: '1.0.0', + requestId: after.headers['x-request-id'], + }); + + await app.close(); + }); + + it('supports custom readiness path and initial readiness state', async () => { + const app = await createServiceApp({ + name: 'custom-ready', + version: '1.0.0', + readiness: { path: '/status/ready', initialReady: true }, + logger: false, + }); + + const res = await app.inject({ method: 'GET', url: '/status/ready' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toMatchObject({ + status: 'ready', + service: 'custom-ready', + }); + + await app.close(); + }); + + it('can omit requestId from error responses when configured', async () => { + const { BadRequestError } = await import('@bytelyst/errors'); + const app = await createServiceApp({ + name: 'error-config', + version: '1.0.0', + logger: false, + errorResponses: { includeRequestId: false }, + }); + + app.get('/bad', async () => { + throw new BadRequestError('No request id please'); + }); + + const res = await app.inject({ method: 'GET', url: '/bad' }); + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.payload); + expect(body.error).toBe('No request id please'); + expect(body.requestId).toBeUndefined(); + + await app.close(); + }); +}); + +describe('registerOptionalJwtContext', () => { + it('attaches jwtPayload when bearer token verification succeeds', async () => { + const app = await createServiceApp({ + name: 'jwt-context', + version: '1.0.0', + logger: false, + }); + + await registerOptionalJwtContext(app, { + verifyToken: async token => ({ sub: `user:${token}`, role: 'admin' }), + }); + + app.get('/secure', async req => ({ jwtPayload: (req as typeof req & JwtRequest).jwtPayload })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer abc123' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toEqual({ + jwtPayload: { sub: 'user:abc123', role: 'admin' }, + }); + + await app.close(); + }); + + it('swallows verification errors by default for optional auth', async () => { + const app = await createServiceApp({ + name: 'jwt-optional', + version: '1.0.0', + logger: false, + }); + + await registerOptionalJwtContext(app, { + verifyToken: async () => { + throw new Error('invalid token'); + }, + }); + + app.get('/secure', async req => ({ + jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, + })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer broken' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload)).toEqual({ jwtPayload: null }); + + await app.close(); + }); + + it('invokes onError callback when token verification fails', async () => { + const app = await createServiceApp({ + name: 'jwt-onerror', + version: '1.0.0', + logger: false, + }); + + const onError = vi.fn(); + + await registerOptionalJwtContext(app, { + verifyToken: async () => { + throw new Error('bad token'); + }, + onError, + }); + + app.get('/secure', async req => ({ + jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, + })); + + const res = await app.inject({ + method: 'GET', + url: '/secure', + headers: { authorization: 'Bearer broken' }, + }); + + expect(res.statusCode).toBe(200); + expect(onError).toHaveBeenCalledOnce(); + + await app.close(); + }); +}); + +describe('startService', () => { + it('sets readiness state after successful listen', async () => { + const app = await createServiceApp({ + name: 'start-ready', + version: '1.0.0', + readiness: true, + logger: false, + }); + + const listenMock = vi.fn(async () => 'http://127.0.0.1:9999'); + const closeMock = vi.fn(async () => undefined); + const infoMock = vi.fn(); + const errorMock = vi.fn(); + + app.listen = listenMock as typeof app.listen; + app.close = closeMock as unknown as typeof app.close; + app.log.info = infoMock as typeof app.log.info; + app.log.error = errorMock as typeof app.log.error; + + expect(app.isReadyState()).toBe(false); + + await startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }); + + expect(listenMock).toHaveBeenCalledWith({ port: 9999, host: '0.0.0.0' }); + expect(app.isReadyState()).toBe(true); + expect(errorMock).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('throws startup error instead of exiting when exitOnFatal is false', async () => { + const app = await createServiceApp({ + name: 'start-fail', + version: '1.0.0', + logger: false, + }); + + const startupError = new Error('listen failed'); + const listenMock = vi.fn(async () => { + throw startupError; + }); + const errorMock = vi.fn(); + + app.listen = listenMock as typeof app.listen; + app.log.error = errorMock as typeof app.log.error; + + await expect( + startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }) + ).rejects.toThrow('listen failed'); + + expect(errorMock).toHaveBeenCalledWith(startupError); + + await app.close(); + }); +}); diff --git a/vendor/bytelyst/fastify-core/src/auth.ts b/vendor/bytelyst/fastify-core/src/auth.ts new file mode 100644 index 0000000..a443989 --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/auth.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify'; +import type { FastifyApp } from './types.js'; + +interface JwtCarrier { + jwtPayload?: unknown; +} + +export interface OptionalJwtContextOptions { + onError?: (error: unknown, request: FastifyRequest) => Promise | void; + verifyToken: (token: string, request: FastifyRequest) => Promise | TPayload; +} + +export async function registerOptionalJwtContext( + app: FastifyApp, + options: OptionalJwtContextOptions +): Promise { + const { verifyToken, onError } = options; + + app.addHook('onRequest', async req => { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return; + + try { + const payload = await verifyToken(auth.slice(7), req); + (req as FastifyRequest & JwtCarrier).jwtPayload = payload; + } catch (error) { + if (onError) { + await onError(error, req); + } + } + }); +} diff --git a/vendor/bytelyst/fastify-core/src/create-app.ts b/vendor/bytelyst/fastify-core/src/create-app.ts new file mode 100644 index 0000000..4c4ad6d --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/create-app.ts @@ -0,0 +1,171 @@ +/** + * Factory for creating a Fastify service app with standard middleware. + * + * Includes: CORS, x-request-id propagation, health endpoint, ServiceError handler. + */ + +import { randomUUID } from 'node:crypto'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { ServiceError } from '@bytelyst/errors'; +import type { ServiceAppOptions, FastifyApp } from './types.js'; + +/** + * Create a Fastify app preconfigured with common middleware. + * + * @example + * ```ts + * const app = await createServiceApp({ + * name: "platform-service", + * version: "0.1.0", + * description: "Auth, audit, flags, notifications", + * corsOrigin: process.env.CORS_ORIGIN, + * }); + * await app.register(authRoutes, { prefix: "/api" }); + * await startService(app, { port: 4003 }); + * ``` + */ +export async function createServiceApp(options: ServiceAppOptions): Promise { + const { + name, + version, + description, + corsOrigin, + logger = true, + logLevel, + swagger, + metrics, + readiness, + errorResponses, + optionalPluginFailure = 'warn', + } = options; + + const app = Fastify({ + logger: logger ? (logLevel ? { level: logLevel } : true) : false, + }) as unknown as FastifyApp; + + let readyState = typeof readiness === 'object' ? (readiness.initialReady ?? false) : false; + const readinessPath = typeof readiness === 'object' ? (readiness.path ?? '/ready') : '/ready'; + const includeRequestIdInErrors = errorResponses?.includeRequestId ?? true; + + app.setReadyState = (ready: boolean) => { + readyState = ready; + }; + + app.isReadyState = () => readyState; + + // CORS — deny all origins when CORS_ORIGIN is not explicitly set + const origin = corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : false; + await app.register(cors, { origin }); + + // OpenAPI spec (optional — consumer must have @fastify/swagger installed) + if (swagger) { + try { + const swaggerPlugin = (await import('@fastify/swagger')).default; + await app.register(swaggerPlugin, { + openapi: { + info: { + title: swagger.title, + version, + ...(swagger.description && { description: swagger.description }), + }, + ...(swagger.port && { servers: [{ url: `http://localhost:${swagger.port}` }] }), + }, + }); + } catch (error) { + if (optionalPluginFailure === 'throw') { + throw error; + } + app.log.warn( + { err: error }, + 'Optional plugin @fastify/swagger not available; skipping OpenAPI registration' + ); + } + + // Swagger UI — serves interactive API docs at /documentation + try { + const swaggerUiPlugin = (await import('@fastify/swagger-ui')).default; + await app.register(swaggerUiPlugin, { + routePrefix: '/documentation', + uiConfig: { docExpansion: 'list', deepLinking: true }, + }); + } catch (error) { + if (optionalPluginFailure === 'throw') { + throw error; + } + app.log.warn( + { err: error }, + 'Optional plugin @fastify/swagger-ui not available; skipping Swagger UI registration' + ); + } + } + + // Prometheus metrics (optional — consumer must have fastify-metrics installed) + if (metrics) { + try { + const metricsMod = await import('fastify-metrics'); + const plugin = metricsMod.default as unknown as Parameters[0]; + await app.register(plugin, { endpoint: '/metrics' }); + } catch (error) { + if (optionalPluginFailure === 'throw') { + throw error; + } + app.log.warn( + { err: error }, + 'Optional plugin fastify-metrics not available; skipping metrics registration' + ); + } + } + + // x-request-id propagation + app.addHook('onRequest', async (req, reply) => { + const requestId = (req.headers['x-request-id'] as string) || randomUUID(); + req.headers['x-request-id'] = requestId; + reply.header('x-request-id', requestId); + req.log = req.log.child({ requestId }); + }); + + // Health check + app.get('/health', async req => ({ + status: 'ok', + service: name, + version, + ...(description && { description }), + timestamp: new Date().toISOString(), + requestId: req.headers['x-request-id'], + })); + + if (readiness) { + app.get(readinessPath, async (req, reply) => { + if (!app.isReadyState()) { + reply.code(503); + } + + return { + status: app.isReadyState() ? 'ready' : 'not_ready', + service: name, + version, + ...(description && { description }), + timestamp: new Date().toISOString(), + requestId: req.headers['x-request-id'], + }; + }); + } + + // ServiceError-aware error handler + app.setErrorHandler((error, req, reply) => { + if (error instanceof ServiceError) { + const body: Record = { error: error.message }; + if (error.details) body.details = error.details; + if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; + reply.code(error.statusCode).send(body); + return; + } + app.log.error(error); + const body: Record = { error: 'Internal server error' }; + if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; + reply.code(500).send(body); + }); + + return app; +} diff --git a/vendor/bytelyst/fastify-core/src/index.ts b/vendor/bytelyst/fastify-core/src/index.ts new file mode 100644 index 0000000..e198e58 --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/index.ts @@ -0,0 +1,4 @@ +export { createServiceApp } from './create-app.js'; +export { registerOptionalJwtContext } from './auth.js'; +export { startService } from './start.js'; +export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js'; diff --git a/vendor/bytelyst/fastify-core/src/start.ts b/vendor/bytelyst/fastify-core/src/start.ts new file mode 100644 index 0000000..a4054d7 --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/start.ts @@ -0,0 +1,45 @@ +/** + * Service startup helper — starts the app and logs the address. + */ + +import type { FastifyApp, StartOptions } from './types.js'; + +const registeredSignalHandlers = new Map>(); + +export async function startService(app: FastifyApp, options: StartOptions): Promise { + const { port, host = '0.0.0.0', exitOnFatal = true, registerSignalHandlers = true } = options; + + // Graceful shutdown on SIGTERM/SIGINT (Docker, K8s, Ctrl-C) + if (registerSignalHandlers) { + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + const appsForSignal = registeredSignalHandlers.get(signal) ?? new WeakSet(); + + if (!appsForSignal.has(app)) { + appsForSignal.add(app); + registeredSignalHandlers.set(signal, appsForSignal); + + process.once(signal, async () => { + app.log.info(`Received ${signal}, shutting down gracefully…`); + await app.close(); + if (exitOnFatal) { + process.exit(0); + } + }); + } + } + } + + try { + await app.listen({ port, host }); + if (typeof app.setReadyState === 'function') { + app.setReadyState(true); + } + app.log.info(`Service listening on ${host}:${port}`); + } catch (err) { + app.log.error(err); + if (exitOnFatal) { + process.exit(1); + } + throw err; + } +} diff --git a/vendor/bytelyst/fastify-core/src/types.ts b/vendor/bytelyst/fastify-core/src/types.ts new file mode 100644 index 0000000..0b2d5cc --- /dev/null +++ b/vendor/bytelyst/fastify-core/src/types.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from 'fastify'; + +export interface SwaggerOptions { + title: string; + description?: string; + port?: number; +} + +export interface ReadinessOptions { + path?: string; + initialReady?: boolean; +} + +export interface ErrorResponseOptions { + includeRequestId?: boolean; +} + +export interface ServiceAppOptions { + name: string; + version: string; + description?: string; + corsOrigin?: string; + logger?: boolean; + logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + swagger?: SwaggerOptions; + metrics?: boolean; + readiness?: boolean | ReadinessOptions; + errorResponses?: ErrorResponseOptions; + optionalPluginFailure?: 'warn' | 'throw'; +} + +export interface StartOptions { + port: number; + host?: string; + exitOnFatal?: boolean; + registerSignalHandlers?: boolean; +} + +export type FastifyApp = FastifyInstance & { + setReadyState: (ready: boolean) => void; + isReadyState: () => boolean; +}; diff --git a/vendor/bytelyst/fastify-core/tsconfig.json b/vendor/bytelyst/fastify-core/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/fastify-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/fastify-sse/package.json b/vendor/bytelyst/fastify-sse/package.json new file mode 100644 index 0000000..279e2e8 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bytelyst/fastify-sse", + "version": "0.3.5", + "description": "Fastify plugin for Server-Sent Events (SSE) — real-time push for ByteLyst product backends", + "type": "module", + "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 --pool forks" + }, + "peerDependencies": { + "fastify": "^5.0.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/fastify-sse/src/hub.test.ts b/vendor/bytelyst/fastify-sse/src/hub.test.ts new file mode 100644 index 0000000..fbfccb7 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/hub.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SSEHub } from './hub.js'; +import { EventEmitter } from 'node:events'; +import type { ServerResponse } from 'node:http'; + +function mockResponse(): ServerResponse { + const emitter = new EventEmitter(); + const chunks: string[] = []; + const res = Object.assign(emitter, { + writeHead: vi.fn(), + write: vi.fn((chunk: string) => { + chunks.push(chunk); + return true; + }), + end: vi.fn(), + _chunks: chunks, + }); + return res as unknown as ServerResponse; +} + +describe('SSEHub', () => { + let hub: SSEHub; + + beforeEach(() => { + hub = new SSEHub(); + }); + + describe('addClient', () => { + it('returns a client ID and sets SSE headers', () => { + const res = mockResponse(); + const id = hub.addClient(res); + expect(id).toMatch(/^sse_/); + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + 'Content-Type': 'text/event-stream', + }) + ); + expect(hub.clientCount).toBe(1); + }); + + it('sends initial connected event', () => { + const res = mockResponse(); + hub.addClient(res); + expect(res.write).toHaveBeenCalledWith(expect.stringContaining('event: connected')); + }); + + it('removes client on close', () => { + const res = mockResponse(); + hub.addClient(res); + expect(hub.clientCount).toBe(1); + res.emit('close'); + expect(hub.clientCount).toBe(0); + }); + }); + + describe('broadcast', () => { + it('sends to all connected clients', () => { + const res1 = mockResponse(); + const res2 = mockResponse(); + hub.addClient(res1); + hub.addClient(res2); + + const sent = hub.broadcast({ event: 'test', data: '{"hello":true}' }); + expect(sent).toBe(2); + // Each res gets: initial connected write + broadcast write = 2 calls + expect(res1.write).toHaveBeenCalledTimes(2); + expect(res2.write).toHaveBeenCalledTimes(2); + }); + + it('returns 0 when no clients', () => { + const sent = hub.broadcast({ data: 'test' }); + expect(sent).toBe(0); + }); + + it('removes clients that throw on write', () => { + const res1 = mockResponse(); + const res2 = mockResponse(); + hub.addClient(res1); + hub.addClient(res2); + + // Make res1 throw on next write + (res1.write as ReturnType).mockImplementationOnce(() => { + throw new Error('broken'); + }); + + hub.broadcast({ data: 'test' }); + // res1 should be removed after the error + expect(hub.clientCount).toBe(1); + }); + }); + + describe('sendToUser', () => { + it('sends only to matching userId', () => { + const res1 = mockResponse(); + const res2 = mockResponse(); + hub.addClient(res1, 'user-a'); + hub.addClient(res2, 'user-b'); + + const sent = hub.sendToUser('user-a', { data: 'targeted' }); + expect(sent).toBe(1); + // res1: connected + targeted = 2 writes + expect(res1.write).toHaveBeenCalledTimes(2); + // res2: only connected = 1 write + expect(res2.write).toHaveBeenCalledTimes(1); + }); + + it('returns 0 when no matching users', () => { + const res = mockResponse(); + hub.addClient(res, 'user-a'); + const sent = hub.sendToUser('user-z', { data: 'nothing' }); + expect(sent).toBe(0); + }); + }); + + describe('heartbeat', () => { + it('sends comment to all clients', () => { + const res = mockResponse(); + hub.addClient(res); + hub.heartbeat(); + expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n'); + }); + }); + + describe('disconnectAll', () => { + it('ends all client responses and clears', () => { + const res1 = mockResponse(); + const res2 = mockResponse(); + hub.addClient(res1); + hub.addClient(res2); + expect(hub.clientCount).toBe(2); + + hub.disconnectAll(); + expect(hub.clientCount).toBe(0); + expect(res1.end).toHaveBeenCalled(); + expect(res2.end).toHaveBeenCalled(); + }); + }); + + describe('formatSSE', () => { + it('formats with event, id, and data', () => { + const res = mockResponse(); + hub.addClient(res); + hub.broadcast({ event: 'task.created', data: '{}', id: 'evt_1' }); + + const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; + expect(lastWrite).toContain('id: evt_1'); + expect(lastWrite).toContain('event: task.created'); + expect(lastWrite).toContain('data: {}'); + expect(lastWrite).toMatch(/\n\n$/); + }); + + it('formats with retry field', () => { + const res = mockResponse(); + hub.addClient(res); + hub.broadcast({ data: 'test', retry: 5000 }); + + const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; + expect(lastWrite).toContain('retry: 5000'); + }); + }); +}); diff --git a/vendor/bytelyst/fastify-sse/src/hub.ts b/vendor/bytelyst/fastify-sse/src/hub.ts new file mode 100644 index 0000000..7436946 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/hub.ts @@ -0,0 +1,143 @@ +/** + * SSE Hub — manages connected clients and broadcasts events. + * Product backends create an SSEHub instance and push events to it; + * the hub fans out to all connected SSE clients. + */ + +import type { ServerResponse } from 'node:http'; + +export interface SSEMessage { + event?: string; + data: string; + id?: string; + retry?: number; +} + +interface ConnectedClient { + id: string; + userId?: string; + res: ServerResponse; + connectedAt: string; +} + +export class SSEHub { + private clients = new Map(); + private clientCounter = 0; + + /** + * Add a new SSE client connection. + * Sets up the SSE headers and returns a client ID. + */ + addClient(res: ServerResponse, userId?: string): string { + const id = `sse_${++this.clientCounter}_${Date.now()}`; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // Send initial connection event + res.write(`event: connected\ndata: ${JSON.stringify({ clientId: id })}\n\n`); + + const client: ConnectedClient = { + id, + userId, + res, + connectedAt: new Date().toISOString(), + }; + + this.clients.set(id, client); + + // Clean up on close + res.on('close', () => { + this.clients.delete(id); + }); + + return id; + } + + /** + * Broadcast an SSE message to all connected clients. + */ + broadcast(message: SSEMessage): number { + let sent = 0; + const formatted = formatSSE(message); + + for (const [id, client] of this.clients) { + try { + client.res.write(formatted); + sent++; + } catch { + this.clients.delete(id); + } + } + + return sent; + } + + /** + * Send an SSE message to a specific user's connections. + */ + sendToUser(userId: string, message: SSEMessage): number { + let sent = 0; + const formatted = formatSSE(message); + + for (const [id, client] of this.clients) { + if (client.userId === userId) { + try { + client.res.write(formatted); + sent++; + } catch { + this.clients.delete(id); + } + } + } + + return sent; + } + + /** + * Send a heartbeat (comment) to all clients to keep connections alive. + */ + heartbeat(): void { + for (const [id, client] of this.clients) { + try { + client.res.write(': heartbeat\n\n'); + } catch { + this.clients.delete(id); + } + } + } + + /** + * Get count of connected clients. + */ + get clientCount(): number { + return this.clients.size; + } + + /** + * Disconnect all clients. + */ + disconnectAll(): void { + for (const [, client] of this.clients) { + try { + client.res.end(); + } catch { + /* already closed */ + } + } + this.clients.clear(); + } +} + +function formatSSE(message: SSEMessage): string { + let output = ''; + if (message.id) output += `id: ${message.id}\n`; + if (message.event) output += `event: ${message.event}\n`; + if (message.retry) output += `retry: ${message.retry}\n`; + output += `data: ${message.data}\n\n`; + return output; +} diff --git a/vendor/bytelyst/fastify-sse/src/index.ts b/vendor/bytelyst/fastify-sse/src/index.ts new file mode 100644 index 0000000..a5f3728 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/index.ts @@ -0,0 +1,4 @@ +export { SSEHub } from './hub.js'; +export { ssePlugin } from './plugin.js'; +export type { SSEPluginOptions, SSEClient } from './plugin.js'; +export { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; diff --git a/vendor/bytelyst/fastify-sse/src/per-request.test.ts b/vendor/bytelyst/fastify-sse/src/per-request.test.ts new file mode 100644 index 0000000..ca21f88 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/per-request.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; +import type { FastifyReply } from 'fastify'; + +function mockReply(): FastifyReply { + const chunks: string[] = []; + return { + raw: { + writeHead: vi.fn(), + write: vi.fn((chunk: string) => { + chunks.push(chunk); + return true; + }), + end: vi.fn(), + _chunks: chunks, + }, + hijack: vi.fn(), + } as unknown as FastifyReply; +} + +describe('per-request SSE helpers', () => { + describe('startSSE', () => { + it('sets correct SSE headers', () => { + const reply = mockReply(); + startSSE(reply); + expect(reply.raw.writeHead).toHaveBeenCalledWith(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + }); + + it('hijacks the reply', () => { + const reply = mockReply(); + startSSE(reply); + expect(reply.hijack).toHaveBeenCalled(); + }); + }); + + describe('sendSSEData', () => { + it('formats object data as JSON', () => { + const reply = mockReply(); + sendSSEData(reply, { hello: true }); + expect(reply.raw.write).toHaveBeenCalledWith('data: {"hello":true}\n\n'); + }); + + it('formats string data without double-encoding', () => { + const reply = mockReply(); + sendSSEData(reply, 'plain text'); + expect(reply.raw.write).toHaveBeenCalledWith('data: plain text\n\n'); + }); + + it('formats number data as JSON', () => { + const reply = mockReply(); + sendSSEData(reply, 42); + expect(reply.raw.write).toHaveBeenCalledWith('data: 42\n\n'); + }); + }); + + describe('sendSSEEvent', () => { + it('formats named event with object data', () => { + const reply = mockReply(); + sendSSEEvent(reply, 'token', { text: 'hi' }); + expect(reply.raw.write).toHaveBeenCalledWith('event: token\ndata: {"text":"hi"}\n\n'); + }); + + it('formats named event with string data', () => { + const reply = mockReply(); + sendSSEEvent(reply, 'status', 'done'); + expect(reply.raw.write).toHaveBeenCalledWith('event: status\ndata: done\n\n'); + }); + }); + + describe('endSSE', () => { + it('sends the [DONE] sentinel', () => { + const reply = mockReply(); + endSSE(reply); + expect(reply.raw.write).toHaveBeenCalledWith('data: [DONE]\n\n'); + }); + + it('ends the stream', () => { + const reply = mockReply(); + endSSE(reply); + expect(reply.raw.end).toHaveBeenCalled(); + }); + }); +}); diff --git a/vendor/bytelyst/fastify-sse/src/per-request.ts b/vendor/bytelyst/fastify-sse/src/per-request.ts new file mode 100644 index 0000000..d1c8dc7 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/per-request.ts @@ -0,0 +1,32 @@ +/** + * Per-request SSE helpers — single-request streaming pattern. + * Used by chat streaming and model comparison endpoints where + * one route handler streams SSE to one client. + */ + +import type { FastifyReply } from 'fastify'; + +export function startSSE(reply: FastifyReply): void { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + reply.hijack(); +} + +export function sendSSEEvent(reply: FastifyReply, event: string, data: unknown): void { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + reply.raw.write(`event: ${event}\ndata: ${payload}\n\n`); +} + +export function sendSSEData(reply: FastifyReply, data: unknown): void { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + reply.raw.write(`data: ${payload}\n\n`); +} + +export function endSSE(reply: FastifyReply): void { + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); +} diff --git a/vendor/bytelyst/fastify-sse/src/plugin.ts b/vendor/bytelyst/fastify-sse/src/plugin.ts new file mode 100644 index 0000000..1c12d64 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/src/plugin.ts @@ -0,0 +1,61 @@ +/** + * Fastify plugin that registers an SSE endpoint. + * Product backends configure the path and optional auth check. + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { SSEHub } from './hub.js'; + +export interface SSEClient { + id: string; + userId?: string; +} + +export interface SSEPluginOptions { + /** Route path for the SSE endpoint (default: /events/stream) */ + path?: string; + /** Extract userId from request (optional, enables per-user targeting) */ + getUserId?: (req: FastifyRequest) => string | undefined; + /** Heartbeat interval in ms (default: 30000, set 0 to disable) */ + heartbeatIntervalMs?: number; + /** The SSE hub instance to use (creates one if not provided) */ + hub?: SSEHub; +} + +export async function ssePlugin( + app: FastifyInstance, + options: SSEPluginOptions = {} +): Promise { + const path = options.path ?? '/events/stream'; + const heartbeatMs = options.heartbeatIntervalMs ?? 30_000; + const hub = options.hub ?? new SSEHub(); + + // Decorate app with the hub so routes can push events + if (!app.hasDecorator('sseHub')) { + app.decorate('sseHub', hub); + } + + // Register SSE endpoint + app.get(path, async (req: FastifyRequest, reply: FastifyReply) => { + const userId = options.getUserId?.(req); + + // Hijack the raw response for SSE streaming + const raw = reply.raw; + hub.addClient(raw, userId); + + // Prevent Fastify from sending its own response + reply.hijack(); + }); + + // Heartbeat timer + let heartbeatTimer: ReturnType | undefined; + if (heartbeatMs > 0) { + heartbeatTimer = setInterval(() => hub.heartbeat(), heartbeatMs); + } + + // Cleanup on close + app.addHook('onClose', async () => { + if (heartbeatTimer) clearInterval(heartbeatTimer); + hub.disconnectAll(); + }); +} diff --git a/vendor/bytelyst/fastify-sse/tsconfig.json b/vendor/bytelyst/fastify-sse/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/fastify-sse/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/feature-flag-client/package.json b/vendor/bytelyst/feature-flag-client/package.json new file mode 100644 index 0000000..c9b7097 --- /dev/null +++ b/vendor/bytelyst/feature-flag-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/feature-flag-client", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe feature flag 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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/feature-flag-client/src/client.test.ts b/vendor/bytelyst/feature-flag-client/src/client.test.ts new file mode 100644 index 0000000..8a1788e --- /dev/null +++ b/vendor/bytelyst/feature-flag-client/src/client.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createFeatureFlagClient } from './client.js'; + +describe('createFeatureFlagClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + platform: 'web', + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should create a client with isEnabled returning false before init', () => { + const client = createFeatureFlagClient(baseConfig); + expect(client.isEnabled('any_flag')).toBe(false); + }); + + it('should fetch flags on init', async () => { + const mockFlags = { premium: true, beta_feature: false }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: mockFlags }), + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + + expect(client.isEnabled('premium')).toBe(true); + expect(client.isEnabled('beta_feature')).toBe(false); + expect(client.isEnabled('nonexistent')).toBe(false); + client.stop(); + }); + + it('should return all flags via getAllFlags', async () => { + const mockFlags = { a: true, b: false }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: mockFlags }), + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + + expect(client.getAllFlags()).toEqual({ a: true, b: false }); + client.stop(); + }); + + it('should send correct headers', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: {} }), + }); + vi.stubGlobal('fetch', fetchMock); + + const client = createFeatureFlagClient(baseConfig); + await client.init({ userId: 'user-123' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/flags/poll?'), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-product-id': 'testapp' }), + }) + ); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('platform=web'); + expect(url).toContain('userId=user-123'); + client.stop(); + }); + + it('should keep existing flags on network error', async () => { + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ flags: { initial: true } }), + }); + } + return Promise.reject(new Error('network')); + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + expect(client.isEnabled('initial')).toBe(true); + + await client.refresh(); + expect(client.isEnabled('initial')).toBe(true); + client.stop(); + }); + + it('should persist to storage when provided', async () => { + const store: Record = {}; + const storage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + }; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: { cached: true } }), + }) + ); + + const client = createFeatureFlagClient({ ...baseConfig, storage }); + await client.init(); + + expect(store['testapp-feature-flags']).toBeDefined(); + expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true }); + client.stop(); + }); + + it('should restore flags from storage on creation', () => { + const store: Record = { + 'testapp-feature-flags': JSON.stringify({ restored: true }), + }; + const storage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + }; + + const client = createFeatureFlagClient({ ...baseConfig, storage }); + expect(client.isEnabled('restored')).toBe(true); + }); + + it('should stop polling on stop()', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: {} }), + }) + ); + + const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 }); + await client.init(); + client.stop(); + + expect(client.isEnabled('anything')).toBe(false); + expect(client.getAllFlags()).toEqual({}); + }); +}); diff --git a/vendor/bytelyst/feature-flag-client/src/client.ts b/vendor/bytelyst/feature-flag-client/src/client.ts new file mode 100644 index 0000000..98c2fe1 --- /dev/null +++ b/vendor/bytelyst/feature-flag-client/src/client.ts @@ -0,0 +1,300 @@ +/** + * Browser/React Native-safe feature flag client for platform-service. + * + * Supports two modes: + * 1. **Polling** (default) — GET /flags/poll on a configurable interval + * 2. **Streaming** — SSE via GET /flags/stream for real-time updates + * + * Both modes support multi-variate evaluation via POST /flags/evaluate. + * No Node.js dependencies — uses globalThis.fetch and EventSource. + * + * @example + * ```ts + * import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; + * + * const flags = createFeatureFlagClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * platform: 'mobile', + * }); + * + * await flags.init({ userId: 'user-123' }); + * + * // Boolean check (legacy) + * if (flags.isEnabled('premium_body_viz')) { ... } + * + * // Multi-variate + * const color = flags.getValue('cta_color', '#000000'); + * const config = flags.getValue('rate_limits', { maxReqs: 100 }); + * + * // Listen for changes (SSE or polling refresh) + * const unsub = flags.onChange((key) => console.log(`${key} changed`)); + * ``` + */ + +import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js'; + +export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient { + const { + baseUrl, + productId, + platform, + pollIntervalMs = 5 * 60 * 1000, + storage, + storagePrefix, + useStreaming = false, + getAccessToken, + } = config; + + const prefix = storagePrefix ?? productId; + const BOOL_KEY = `${prefix}-feature-flags`; + const EVAL_KEY = `${prefix}-feature-evals`; + + let boolFlags: Record = {}; + let evaluations: Record = {}; + let initialized = false; + let intervalId: ReturnType | null = null; + // eslint-disable-next-line no-undef + let eventSource: InstanceType | null = null; + let userId: string | undefined; + const listeners = new Set<(flagKey: string) => void>(); + + // Restore from storage on creation + if (storage) { + try { + const cached = storage.getItem(BOOL_KEY); + if (cached) boolFlags = JSON.parse(cached); + } catch { + /* Ignore parse errors */ + } + try { + const cached = storage.getItem(EVAL_KEY); + if (cached) evaluations = JSON.parse(cached); + } catch { + /* Ignore parse errors */ + } + } + + function buildHeaders(): Record { + const requestId = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + const headers: Record = { + 'x-product-id': productId, + 'x-request-id': requestId, + }; + if (getAccessToken) { + const token = getAccessToken(); + if (token) headers['authorization'] = `Bearer ${token}`; + } + return headers; + } + + function notifyListeners(flagKey: string): void { + for (const listener of listeners) { + try { + listener(flagKey); + } catch { + /* best-effort */ + } + } + } + + function persistToStorage(): void { + if (!storage) return; + try { + storage.setItem(BOOL_KEY, JSON.stringify(boolFlags)); + } catch { + /* non-fatal */ + } + try { + storage.setItem(EVAL_KEY, JSON.stringify(evaluations)); + } catch { + /* non-fatal */ + } + } + + async function fetchBoolFlags(): Promise { + try { + const parts = [`platform=${encodeURIComponent(platform)}`]; + if (userId) parts.push(`userId=${encodeURIComponent(userId)}`); + + const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, { + headers: buildHeaders(), + }); + + if (!res.ok) return; + + const data = (await res.json()) as { flags?: Record }; + const prev = boolFlags; + boolFlags = data.flags ?? {}; + + // Detect changes and notify + const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]); + for (const key of allKeys) { + if (prev[key] !== boolFlags[key]) notifyListeners(key); + } + + persistToStorage(); + } catch { + // Keep existing flags on network error + } + } + + async function fetchEvaluations(): Promise { + try { + const body: Record = { platform }; + if (userId) body.userId = userId; + + const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, { + method: 'POST', + headers: { ...buildHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) return; + + const data = (await res.json()) as { evaluations?: Record }; + const prev = evaluations; + evaluations = data.evaluations ?? {}; + + // Also update bool flags for backward compatibility + for (const [key, result] of Object.entries(evaluations)) { + const newBool = + result.reason !== 'off' && + result.reason !== 'prerequisite_failed' && + result.reason !== 'schedule_inactive' && + result.reason !== 'error'; + if (boolFlags[key] !== newBool) { + boolFlags[key] = newBool; + notifyListeners(key); + } else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) { + notifyListeners(key); + } + } + + persistToStorage(); + } catch { + // Keep existing evaluations on network error + } + } + + async function fetchAll(): Promise { + await Promise.all([fetchBoolFlags(), fetchEvaluations()]); + } + + function startStreaming(): void { + if (typeof globalThis.EventSource === 'undefined') { + // SSE not available (e.g. React Native) — fall back to polling + intervalId = setInterval(() => { + void fetchAll(); + }, pollIntervalMs); + return; + } + + const parts = [`productId=${encodeURIComponent(productId)}`]; + if (getAccessToken) { + const token = getAccessToken(); + if (token) parts.push(`token=${encodeURIComponent(token)}`); + } + const url = `${baseUrl}/flags/stream?${parts.join('&')}`; + eventSource = new globalThis.EventSource(url); + + eventSource.onmessage = event => { + try { + const data = JSON.parse(event.data) as { type?: string; flagKey?: string }; + if (data.type === 'flag_change' && data.flagKey) { + // Re-fetch all evaluations on any flag change + void fetchAll().then(() => notifyListeners(data.flagKey!)); + } + } catch { + /* Ignore parse errors */ + } + }; + + eventSource.onerror = () => { + // Reconnect handled automatically by EventSource spec + }; + } + + async function init(params?: { userId?: string }): Promise { + if (initialized) return; + initialized = true; + userId = params?.userId; + + await fetchAll(); + + if (useStreaming) { + startStreaming(); + } else { + intervalId = setInterval(() => { + void fetchAll(); + }, pollIntervalMs); + } + } + + function isEnabled(key: string): boolean { + return boolFlags[key] === true; + } + + function getValue>( + key: string, + defaultValue: T + ): T { + const result = evaluations[key]; + if (!result) return defaultValue; + if (result.reason === 'off' || result.reason === 'error') return defaultValue; + return result.value as T; + } + + function getEvaluation(key: string): EvaluationResult | undefined { + return evaluations[key]; + } + + function getAllFlags(): Readonly> { + return boolFlags; + } + + function getAllEvaluations(): Readonly> { + return evaluations; + } + + async function refresh(): Promise { + await fetchAll(); + } + + function onChange(listener: (flagKey: string) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function stop(): void { + if (intervalId) clearInterval(intervalId); + intervalId = null; + if (eventSource) { + eventSource.close(); + eventSource = null; + } + boolFlags = {}; + evaluations = {}; + initialized = false; + userId = undefined; + listeners.clear(); + } + + return { + init, + isEnabled, + getValue, + getEvaluation, + getAllFlags, + getAllEvaluations, + refresh, + onChange, + stop, + }; +} diff --git a/vendor/bytelyst/feature-flag-client/src/index.ts b/vendor/bytelyst/feature-flag-client/src/index.ts new file mode 100644 index 0000000..cd426b3 --- /dev/null +++ b/vendor/bytelyst/feature-flag-client/src/index.ts @@ -0,0 +1,7 @@ +export { createFeatureFlagClient } from './client.js'; +export type { + FeatureFlagClient, + FeatureFlagClientConfig, + EvaluationContext, + EvaluationResult, +} from './types.js'; diff --git a/vendor/bytelyst/feature-flag-client/src/types.ts b/vendor/bytelyst/feature-flag-client/src/types.ts new file mode 100644 index 0000000..cda332a --- /dev/null +++ b/vendor/bytelyst/feature-flag-client/src/types.ts @@ -0,0 +1,88 @@ +/** + * Types for @bytelyst/feature-flag-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +// ── Evaluation types ──────────────────────────────────────────────────────── + +export interface EvaluationContext { + userId?: string; + platform?: string; + region?: string; + osVersion?: string; + appVersion?: string; + email?: string; + custom?: Record; +} + +export interface EvaluationResult { + key: string; + value: boolean | string | number | Record; + variationKey: string; + reason: string; +} + +// ── Config ────────────────────────────────────────────────────────────────── + +export interface FeatureFlagClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Platform string for the poll query (e.g. "mobile", "web"). */ + platform: string; + + /** Poll interval in milliseconds. Default: 5 minutes. */ + pollIntervalMs?: number; + + /** Optional persistent storage adapter for flag cache. */ + storage?: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + }; + + /** Optional storage key prefix. Default: productId. */ + storagePrefix?: string; + + /** Use SSE for real-time updates instead of polling. Default: false. */ + useStreaming?: boolean; + + /** Auth token getter for authenticated requests. */ + getAccessToken?: () => string | null; +} + +// ── Client interface ──────────────────────────────────────────────────────── + +export interface FeatureFlagClient { + /** Initialize the client: fetch flags immediately and start polling/streaming. */ + init(params?: { userId?: string }): Promise; + + /** Check if a boolean feature flag is enabled. Returns false if not found. */ + isEnabled(key: string): boolean; + + /** Get the resolved value of a multi-variate flag. Returns defaultValue if not found. */ + getValue>( + key: string, + defaultValue: T + ): T; + + /** Get the full evaluation result for a flag. Returns undefined if not found. */ + getEvaluation(key: string): EvaluationResult | undefined; + + /** Get all currently cached boolean flags (legacy format). */ + getAllFlags(): Readonly>; + + /** Get all evaluation results (multi-variate format). */ + getAllEvaluations(): Readonly>; + + /** Force a refresh of feature flags. */ + refresh(): Promise; + + /** Register a listener for flag changes. Returns unsubscribe function. */ + onChange(listener: (flagKey: string) => void): () => void; + + /** Stop polling/streaming and reset state. */ + stop(): void; +} diff --git a/vendor/bytelyst/feature-flag-client/tsconfig.json b/vendor/bytelyst/feature-flag-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/feature-flag-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/vendor/bytelyst/feedback-client/package.json b/vendor/bytelyst/feedback-client/package.json new file mode 100644 index 0000000..3dafade --- /dev/null +++ b/vendor/bytelyst/feedback-client/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bytelyst/feedback-client", + "version": "0.1.5", + "description": "TypeScript client for submitting user feedback with screenshots", + "type": "module", + "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 --pool forks" + }, + "dependencies": { + "@bytelyst/api-client": "workspace:*" + }, + "peerDependencies": { + "zod": "^3.22.0" + }, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/feedback-client/src/gdpr.test.ts b/vendor/bytelyst/feedback-client/src/gdpr.test.ts new file mode 100644 index 0000000..c5a1077 --- /dev/null +++ b/vendor/bytelyst/feedback-client/src/gdpr.test.ts @@ -0,0 +1,140 @@ +/** + * GDPR deletion test for feedback screenshots + * + * Tests the Right to be Forgotten compliance: + * 1. User submits feedback with screenshot + * 2. Admin deletes feedback and screenshot + * 3. Blob storage reference removed (actual deletion by lifecycle policy) + * + * TODO-5: GDPR deletion compliance test + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createFeedbackClient, type FeedbackClient } from './index.js'; + +// Check if blob storage is available +const blobStorageAvailable = !!( + process.env.AZURE_BLOB_CONNECTION_STRING || + (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) +); + +const describeIntegration = blobStorageAvailable ? describe : describe.skip; + +describeIntegration('GDPR Deletion Compliance (TODO-5)', () => { + let client: FeedbackClient; + const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; + const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; + const adminToken = process.env.TEST_ADMIN_TOKEN || 'admin-token'; + + beforeAll(() => { + client = createFeedbackClient({ + baseUrl: testBaseUrl, + getAuthToken: () => testAuthToken, + }); + }); + + it('should delete feedback and screenshot on user request (GDPR)', async () => { + // Step 1: Submit feedback with screenshot + const testPngData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, + 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, + 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + const testBlob = new Blob([testPngData], { type: 'image/png' }); + + const submitResult = await client.submitWithScreenshot({ + type: 'bug', + title: 'GDPR test feedback', + body: 'This feedback will be deleted per user request', + screenshot: { + blob: testBlob, + contentType: 'image/png', + }, + }); + + expect(submitResult.screenshotBlobPath).toBeDefined(); + const feedbackId = submitResult.id; + + // Step 2: Delete feedback (admin action) + const deleteRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(deleteRes.status).toBe(204); + + // Step 3: Verify feedback no longer exists + const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(getRes.status).toBe(404); + + // Step 4: Verify screenshot reference is gone + // Note: Actual blob deletion is handled by Azure lifecycle policy + // This test verifies the database reference is removed + const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(screenshotRes.status).toBe(404); + + // GDPR deletion verified — blob will be purged by Azure lifecycle policy within 90 days + }, 30000); + + it('should delete only screenshot while keeping feedback (partial deletion)', async () => { + // Submit feedback with screenshot + const testBlob = new Blob(['test'], { type: 'image/png' }); + const submitResult = await client.submitWithScreenshot({ + type: 'feature', + title: 'Partial deletion test', + screenshot: { + blob: testBlob, + contentType: 'image/png', + }, + }); + + const feedbackId = submitResult.id; + + // Delete just the screenshot + const deleteScreenshotRes = await fetch( + `${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminToken}` }, + } + ); + expect(deleteScreenshotRes.status).toBe(204); + + // Verify feedback still exists but screenshot is gone + const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(getRes.status).toBe(200); + + const feedback = await getRes.json(); + expect(feedback.screenshotBlobPath).toBeNull(); + + // Cleanup + await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${adminToken}` }, + }); + }, 30000); +}); + +describe('GDPR Compliance Checklist', () => { + it('documents GDPR requirements', () => { + const gdprRequirements = [ + '✅ User can request deletion of their feedback', + '✅ Admin can delete feedback and screenshots', + '✅ Screenshot blob reference is removed from database', + '✅ Feedback data removed from Cosmos DB', + '✅ Azure lifecycle policy purges blob within 90 days', + '✅ Deletion is irreversible (no soft-delete)', + ]; + + // All GDPR requirements satisfied — see gdprRequirements array above + + expect(gdprRequirements.length).toBeGreaterThan(0); + }); +}); diff --git a/vendor/bytelyst/feedback-client/src/index.test.ts b/vendor/bytelyst/feedback-client/src/index.test.ts new file mode 100644 index 0000000..dbbe693 --- /dev/null +++ b/vendor/bytelyst/feedback-client/src/index.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { FeedbackClient } from './index.js'; +import type { ApiClient } from '@bytelyst/api-client'; + +describe('FeedbackClient', () => { + const mockApi: Partial = { + fetch: vi.fn(), + }; + + const createClient = () => new FeedbackClient(mockApi as ApiClient); + + it('should submit feedback without screenshot', async () => { + const client = createClient(); + const mockResponse = { + id: 'fb_123', + productId: 'test', + userId: 'user_123', + type: 'bug', + title: 'Test bug', + status: 'new', + createdAt: new Date().toISOString(), + }; + + mockApi.fetch = vi.fn().mockResolvedValue(mockResponse); + + const result = await client.submitWithScreenshot({ + type: 'bug', + title: 'Test bug', + body: 'Description', + }); + + expect(result).toEqual(mockResponse); + expect(mockApi.fetch).toHaveBeenCalledWith( + '/api/feedback', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('should throw if captureAndSubmit called without screenshot', async () => { + const client = createClient(); + + await expect(client.captureAndSubmit({ type: 'bug', title: 'Test' })).rejects.toThrow( + 'Screenshot capture only available in browser environment' + ); + }); +}); diff --git a/vendor/bytelyst/feedback-client/src/index.ts b/vendor/bytelyst/feedback-client/src/index.ts new file mode 100644 index 0000000..52f1876 --- /dev/null +++ b/vendor/bytelyst/feedback-client/src/index.ts @@ -0,0 +1,384 @@ +/** + * Feedback Client — TypeScript SDK for user feedback with screenshots + * + * @module @bytelyst/feedback-client + */ + +import { createApiClient, type ApiClient } from '@bytelyst/api-client'; + +export interface FeedbackClientConfig { + baseUrl: string; + getAuthToken: () => string | null; +} + +export interface DeviceContext { + osVersion: string; + appVersion: string; + deviceModel: string; + screenResolution: string; + locale: string; +} + +export interface SubmitFeedbackParams { + type: 'bug' | 'feature' | 'praise' | 'other'; + title: string; + body?: string; + screen?: string; + rating?: number; + appVersion?: string; + platform?: 'web' | 'ios' | 'android'; + screenshot?: { + blob: Blob; + contentType: 'image/png' | 'image/jpeg' | 'image/webp'; + }; + deviceContext?: DeviceContext; +} + +export interface SasResponse { + blobPath: string; + uploadUrl: string; + expiresIn: number; + maxSizeBytes: number; +} + +export interface FeedbackResponse { + id: string; + productId: string; + userId: string; + type: string; + title: string; + status: string; + createdAt: string; + screenshotBlobPath?: string; +} + +export type UploadProgressCallback = (loaded: number, total: number) => void; + +export interface ScreenshotOptions { + /** For web: CSS selector of element to capture. If omitted, captures viewport */ + selector?: string; + /** Image format */ + format?: 'png' | 'jpeg' | 'webp'; + /** JPEG quality (0-1), only used for jpeg format */ + quality?: number; +} + +export interface CaptureResult { + blob: Blob; + contentType: 'image/png' | 'image/jpeg' | 'image/webp'; + width: number; + height: number; +} + +/** + * Create a feedback client for submitting user feedback with optional screenshots + */ +export function createFeedbackClient(config: FeedbackClientConfig) { + const api = createApiClient({ + baseUrl: config.baseUrl, + getToken: config.getAuthToken, + }); + + return new FeedbackClient(api); +} + +/** + * Feedback client class for submitting feedback with screenshots + */ +export class FeedbackClient { + constructor(private api: ApiClient) {} + + /** + * Submit feedback with optional screenshot + * + * Flow: + * 1. If screenshot provided, get SAS URL + * 2. Upload screenshot to blob storage + * 3. Submit feedback with screenshot metadata + */ + async submitWithScreenshot( + params: SubmitFeedbackParams, + onProgress?: UploadProgressCallback + ): Promise { + let screenshotMeta: { blobPath: string; contentType: string; sizeBytes: number } | undefined; + + // Step 1 & 2: Handle screenshot upload if provided + if (params.screenshot) { + const sas = await this.generateSasUrl(params.screenshot.contentType); + + await this.uploadScreenshot(sas.uploadUrl, params.screenshot.blob, onProgress); + + screenshotMeta = { + blobPath: sas.blobPath, + contentType: params.screenshot.contentType, + sizeBytes: params.screenshot.blob.size, + }; + } + + // Step 3: Submit feedback + const response = await this.api.fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: params.type, + title: params.title, + body: params.body, + screen: params.screen, + rating: params.rating, + appVersion: params.appVersion, + platform: params.platform, + screenshotBlobPath: screenshotMeta?.blobPath, + screenshotContentType: screenshotMeta?.contentType as + | 'image/png' + | 'image/jpeg' + | 'image/webp', + screenshotSizeBytes: screenshotMeta?.sizeBytes, + deviceContext: params.deviceContext, + }), + }); + + return response; + } + + /** + * Generate SAS URL for screenshot upload + */ + private async generateSasUrl( + contentType: 'image/png' | 'image/jpeg' | 'image/webp' + ): Promise { + const response = await this.api.fetch('/api/feedback/sas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contentType }), + }); + return response; + } + + /** + * Upload screenshot directly to Azure Blob + */ + private async uploadScreenshot( + uploadUrl: string, + blob: Blob, + onProgress?: UploadProgressCallback + ): Promise { + // Use XMLHttpRequest for progress tracking if callback provided + if (onProgress && typeof window !== 'undefined') { + return this.uploadWithProgress(uploadUrl, blob, onProgress); + } + + // Simple fetch upload + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': blob.type, + 'x-ms-blob-type': 'BlockBlob', + }, + body: blob, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + } + + /** + * Upload with progress tracking using XMLHttpRequest + */ + private uploadWithProgress( + uploadUrl: string, + blob: Blob, + onProgress: UploadProgressCallback + ): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (event: ProgressEvent) => { + if (event.lengthComputable) { + onProgress(event.loaded, event.total); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Upload failed: Network error')); + }); + + xhr.open('PUT', uploadUrl); + xhr.setRequestHeader('Content-Type', blob.type); + xhr.setRequestHeader('x-ms-blob-type', 'BlockBlob'); + xhr.send(blob); + }); + } + + /** + * Capture screenshot of current page or element (Web only) + * + * Uses native getDisplayMedia for screen capture or html2canvas-style + * DOM serialization for element capture. + */ + async captureScreenshot(options: ScreenshotOptions = {}): Promise { + // Check if running in browser + if (typeof window === 'undefined' || typeof document === 'undefined') { + throw new Error('Screenshot capture only available in browser environment'); + } + + const format = options.format || 'png'; + const mimeType = + format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp'; + + // If selector provided, capture specific element + if (options.selector) { + return this.captureElement(options.selector, mimeType, options.quality); + } + + // Otherwise capture full screen using getDisplayMedia + return this.captureScreen(mimeType); + } + + /** + * Capture entire screen using getDisplayMedia + */ + private async captureScreen(mimeType: string): Promise { + try { + // Request screen capture permission + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, + }); + + // Create video element to capture frame + const video = document.createElement('video'); + video.srcObject = stream; + + // Wait for video to load + await new Promise((resolve, reject) => { + video.onloadedmetadata = () => { + video.play(); + resolve(); + }; + video.onerror = () => reject(new Error('Failed to load video stream')); + // Timeout after 5 seconds + setTimeout(() => reject(new Error('Video load timeout')), 5000); + }); + + // Draw to canvas + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + + ctx.drawImage(video, 0, 0); + + // Stop all tracks + stream.getTracks().forEach(track => track.stop()); + + // Convert to blob + const quality = mimeType === 'image/jpeg' ? 0.9 : undefined; + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + b => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), + mimeType, + quality + ); + }); + + return { + blob, + contentType: mimeType as 'image/png' | 'image/jpeg' | 'image/webp', + width: canvas.width, + height: canvas.height, + }; + } catch (err) { + throw new Error( + `Screen capture failed: ${err instanceof Error ? err.message : String(err)}`, + { cause: err } + ); + } + } + + /** + * Capture specific DOM element using html-to-image approach. + * `mimeType` and `quality` are accepted for API parity with captureScreen() + * but are not yet honoured here (this path always returns the fallback PNG). + */ + private async captureElement( + selector: string, + _mimeType: string, + _quality?: number + ): Promise { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Element not found: ${selector}`); + } + + // Use html-to-image or similar approach + // For now, we'll use a simple canvas-based approach for visible elements + const rect = element.getBoundingClientRect(); + + // Create canvas + const canvas = document.createElement('canvas'); + canvas.width = rect.width; + canvas.height = rect.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to get canvas context'); + + // Try to use html2canvas-style approach if available, otherwise warn + // This is a simplified implementation + throw new Error( + 'Element capture requires html2canvas library. ' + + 'Please install: npm install html2canvas ' + + 'Then use: html2canvas(element).then(canvas => canvas.toBlob(...))' + ); + } + + /** + * Capture and submit feedback in one operation + * + * @example + * // Capture full screen and submit + * const result = await client.captureAndSubmit({ + * type: 'bug', + * title: 'Something is broken', + * body: 'Description of the issue' + * }); + * + * @example + * // Capture specific element + * const result = await client.captureAndSubmit({ + * type: 'bug', + * title: 'Button not working', + * body: 'The submit button is unresponsive' + * }, { + * selector: '#submit-button' + * }); + */ + async captureAndSubmit( + params: Omit, + screenshotOptions?: ScreenshotOptions, + onProgress?: UploadProgressCallback + ): Promise { + // Capture screenshot + const capture = await this.captureScreenshot(screenshotOptions); + + // Submit with captured screenshot + return this.submitWithScreenshot( + { + ...params, + screenshot: { + blob: capture.blob, + contentType: capture.contentType, + }, + }, + onProgress + ); + } +} diff --git a/vendor/bytelyst/feedback-client/src/integration.test.ts b/vendor/bytelyst/feedback-client/src/integration.test.ts new file mode 100644 index 0000000..3451254 --- /dev/null +++ b/vendor/bytelyst/feedback-client/src/integration.test.ts @@ -0,0 +1,210 @@ +/** + * Integration tests for feedback screenshot flow + * + * These tests verify the complete flow: + * 1. Generate SAS URL for upload + * 2. Upload screenshot to blob storage + * 3. Submit feedback with screenshot metadata + * 4. Retrieve feedback with screenshot URL + * + * NOTE: Requires blob storage to be available in test environment. + * Tests are auto-skipped when AZURE_BLOB_CONNECTION_STRING is not set. + * In CI, set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME+KEY. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createFeedbackClient, type FeedbackClient } from './index.js'; + +// Check if blob storage is available +const blobStorageAvailable = !!( + process.env.AZURE_BLOB_CONNECTION_STRING || + (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) +); + +const describeIntegration = blobStorageAvailable ? describe : describe.skip; + +describeIntegration('Feedback Screenshot Integration', () => { + let client: FeedbackClient; + const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; + const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; + + beforeAll(() => { + client = createFeedbackClient({ + baseUrl: testBaseUrl, + getAuthToken: () => testAuthToken, + }); + }); + + it('should complete full screenshot submission flow', async () => { + // Create a test image blob (1x1 pixel PNG) + const testPngData = new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, // IHDR chunk + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, // 1x1 pixel + 0x08, + 0x02, + 0x00, + 0x00, + 0x00, + 0x90, + 0x77, + 0x53, + 0xde, + 0x00, + 0x00, + 0x00, + 0x0c, + 0x49, + 0x44, + 0x41, // IDAT chunk + 0x54, + 0x08, + 0xd7, + 0x63, + 0xf8, + 0xcf, + 0xc0, + 0x00, + 0x00, + 0x03, + 0x01, + 0x01, + 0x00, + 0x18, + 0xdd, + 0x8d, + 0xb4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, // IEND chunk + 0x44, + 0xae, + 0x42, + 0x60, + 0x82, + ]); + const testBlob = new Blob([testPngData], { type: 'image/png' }); + + // Submit feedback with screenshot + const result = await client.submitWithScreenshot({ + type: 'bug', + title: 'Integration test screenshot', + body: 'This is a test feedback with screenshot', + screenshot: { + blob: testBlob, + contentType: 'image/png', + }, + deviceContext: { + osVersion: 'Test OS 1.0', + appVersion: '1.0.0', + deviceModel: 'Test Device', + screenResolution: '1920x1080', + locale: 'en-US', + }, + }); + + // Verify response + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.type).toBe('bug'); + expect(result.title).toBe('Integration test screenshot'); + expect(result.status).toBe('new'); + expect(result.screenshotBlobPath).toBeDefined(); + expect(result.screenshotBlobPath).toContain('feedbackScreenshots'); + + // TODO: Verify screenshot can be retrieved via admin API + // const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${result.id}/screenshot`, { + // headers: { Authorization: `Bearer ${adminToken}` }, + // }); + // expect(screenshotRes.ok).toBe(true); + }, 30000); // 30 second timeout for upload + + it('should submit feedback without screenshot', async () => { + const result = await client.submitWithScreenshot({ + type: 'feature', + title: 'Integration test without screenshot', + body: 'This is a test feedback without screenshot', + }); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.type).toBe('feature'); + expect(result.screenshotBlobPath).toBeUndefined(); + }); + + it('should track upload progress', async () => { + const progressCallbacks: number[] = []; + const testBlob = new Blob(['test data'], { type: 'image/png' }); + + try { + await client.submitWithScreenshot( + { + type: 'bug', + title: 'Progress test', + screenshot: { + blob: testBlob, + contentType: 'image/png', + }, + }, + (loaded, _total) => { + progressCallbacks.push(loaded); + } + ); + } catch { + // Expected to fail with invalid PNG, but progress should still be called + } + + // Progress callback may or may not be called depending on upload speed + // Just verify the callback mechanism exists + expect(progressCallbacks).toBeDefined(); + }); +}); + +describe('Feedback Client Unit Tests (no blob storage required)', () => { + it('should validate screenshot content types', () => { + const validTypes = ['image/png', 'image/jpeg', 'image/webp']; + const invalidTypes = ['image/gif', 'application/pdf', 'text/plain']; + + for (const type of validTypes) { + expect(type).toMatch(/^image\/(png|jpeg|webp)$/); + } + + for (const type of invalidTypes) { + expect(type).not.toMatch(/^image\/(png|jpeg|webp)$/); + } + }); + + it('should enforce 5MB size limit', () => { + const maxSize = 5 * 1024 * 1024; // 5MB + const underLimit = maxSize - 1; + const overLimit = maxSize + 1; + + expect(underLimit).toBeLessThanOrEqual(maxSize); + expect(overLimit).toBeGreaterThan(maxSize); + }); +}); diff --git a/vendor/bytelyst/feedback-client/tsconfig.json b/vendor/bytelyst/feedback-client/tsconfig.json new file mode 100644 index 0000000..ce78e59 --- /dev/null +++ b/vendor/bytelyst/feedback-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vendor/bytelyst/field-encrypt/package.json b/vendor/bytelyst/field-encrypt/package.json new file mode 100644 index 0000000..0a184be --- /dev/null +++ b/vendor/bytelyst/field-encrypt/package.json @@ -0,0 +1,43 @@ +{ + "name": "@bytelyst/field-encrypt", + "version": "0.1.5", + "type": "module", + "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 --pool forks" + }, + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "peerDependencies": { + "@azure/keyvault-keys": ">=4.8.0", + "@azure/identity": ">=4.0.0", + "zod": ">=3.22.0" + }, + "peerDependenciesMeta": { + "@azure/keyvault-keys": { + "optional": true + }, + "@azure/identity": { + "optional": true + } + }, + "devDependencies": { + "vitest": "^3.0.0", + "zod": "^3.24.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/field-encrypt/src/aes-gcm.ts b/vendor/bytelyst/field-encrypt/src/aes-gcm.ts new file mode 100644 index 0000000..4eedc37 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/aes-gcm.ts @@ -0,0 +1,89 @@ +/** + * @bytelyst/field-encrypt — AES-256-GCM primitives + * + * Low-level encrypt/decrypt using Node.js native crypto. + * All higher-level APIs delegate to these functions. + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import type { EncryptedField } from './types.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const KEY_BYTES = 32; + +/** + * Encrypt a plaintext string with AES-256-GCM. + * + * @param plaintext - UTF-8 string to encrypt + * @param key - 32-byte AES key + * @param dekId - DEK identifier stored in the output + * @param aad - Optional additional authenticated data (e.g., userId + context) + * @returns EncryptedField object ready for Cosmos/SQLite storage + */ +export function encryptField( + plaintext: string, + key: Buffer, + dekId: string, + aad?: string +): EncryptedField { + if (key.length !== KEY_BYTES) { + throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); + } + + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, key, iv); + + if (aad) { + cipher.setAAD(Buffer.from(aad, 'utf8')); + } + + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + return { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: encrypted, + iv: iv.toString('hex'), + tag: authTag.toString('hex'), + dekId, + }; +} + +/** + * Decrypt an EncryptedField back to plaintext. + * + * @param field - EncryptedField object + * @param key - 32-byte AES key (must match the key used to encrypt) + * @param aad - Optional AAD (must match the AAD used during encryption) + * @returns Decrypted UTF-8 string + * @throws Error if authentication tag verification fails (tampered data) + */ +export function decryptField(field: EncryptedField, key: Buffer, aad?: string): string { + if (key.length !== KEY_BYTES) { + throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); + } + + const iv = Buffer.from(field.iv, 'hex'); + const authTag = Buffer.from(field.tag, 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + + decipher.setAuthTag(authTag); + + if (aad) { + decipher.setAAD(Buffer.from(aad, 'utf8')); + } + + let decrypted = decipher.update(field.ct, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +/** Generate a random 32-byte AES-256 key. */ +export function generateAesKey(): Buffer { + return randomBytes(KEY_BYTES); +} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts b/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts new file mode 100644 index 0000000..4d322aa --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts @@ -0,0 +1,75 @@ +/** + * @bytelyst/field-encrypt — Cosmos DB DEK store + * + * Production DEK store backed by Azure Cosmos DB. + * Container: `_encryption_keys` (partition key: /dekId) + * + * Each WrappedDek is stored as a document with dekId as both id and partition key. + */ + +import type { Container } from '@azure/cosmos'; +import type { DekStore, WrappedDek } from './types.js'; + +interface CosmosDekDoc { + id: string; + dekId: string; + wrappedKey: string; + mekVersion: string; + createdAt: string; +} + +export class CosmosDekStore implements DekStore { + constructor(private readonly container: Container) {} + + async get(dekId: string): Promise { + try { + const { resource } = await this.container.item(dekId, dekId).read(); + if (!resource) return null; + return { + dekId: resource.dekId, + wrappedKey: resource.wrappedKey, + mekVersion: resource.mekVersion, + createdAt: resource.createdAt, + }; + } catch (err: unknown) { + if (isNotFound(err)) return null; + throw err; + } + } + + async put(dek: WrappedDek): Promise { + const doc: CosmosDekDoc = { + id: dek.dekId, + dekId: dek.dekId, + wrappedKey: dek.wrappedKey, + mekVersion: dek.mekVersion, + createdAt: dek.createdAt, + }; + await this.container.items.upsert(doc); + } + + async listIds(): Promise { + const { resources } = await this.container.items + .query<{ dekId: string }>('SELECT c.dekId FROM c') + .fetchAll(); + return resources.map(r => r.dekId); + } + + async delete(dekId: string): Promise { + try { + await this.container.item(dekId, dekId).delete(); + } catch (err: unknown) { + if (isNotFound(err)) return; // Already deleted — idempotent + throw err; + } + } +} + +function isNotFound(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code: number }).code === 404 + ); +} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts b/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts new file mode 100644 index 0000000..79aabc3 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts @@ -0,0 +1,27 @@ +/** + * @bytelyst/field-encrypt — In-memory DEK store + * + * Default DEK store for dev/test. Production should use a Cosmos-backed store. + */ + +import type { DekStore, WrappedDek } from './types.js'; + +export class MemoryDekStore implements DekStore { + private readonly deks = new Map(); + + async get(dekId: string): Promise { + return this.deks.get(dekId) ?? null; + } + + async put(dek: WrappedDek): Promise { + this.deks.set(dek.dekId, dek); + } + + async listIds(): Promise { + return [...this.deks.keys()]; + } + + async delete(dekId: string): Promise { + this.deks.delete(dekId); + } +} diff --git a/vendor/bytelyst/field-encrypt/src/envelope.ts b/vendor/bytelyst/field-encrypt/src/envelope.ts new file mode 100644 index 0000000..a062db3 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/envelope.ts @@ -0,0 +1,107 @@ +/** + * @bytelyst/field-encrypt — Envelope encryption + * + * DEK lifecycle: generate → wrap with MEK → store. + * On use: load wrapped DEK → unwrap with MEK → cache → use for AES-GCM. + */ + +import type { KeyProvider, DekStore, WrappedDek } from './types.js'; +import { generateAesKey } from './aes-gcm.js'; +import { DekCache } from './key-cache.js'; + +/** + * Build a deterministic DEK ID from userId + context. + * Format: `dek_{userId}_{context}` + */ +export function buildDekId(userId: string, context: string): string { + return `dek_${userId}_${context}`; +} + +/** + * Get or create a DEK for the given scope. + * + * 1. Check cache → return if found + * 2. Check DEK store → unwrap + cache if found + * 3. Generate new DEK → wrap → store → cache → return + */ +export async function getOrCreateDek( + dekId: string, + keyProvider: KeyProvider, + dekStore: DekStore, + cache: DekCache +): Promise { + // 1. Cache hit + const cached = cache.get(dekId); + if (cached) { + cache.recordHit(); + return cached; + } + cache.recordMiss(); + + // 2. DEK store hit — unwrap + cache + const stored = await dekStore.get(dekId); + if (stored) { + const dek = await keyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); + cache.set(dekId, dek); + return dek; + } + + // 3. Generate new DEK → wrap → store → cache + const dek = generateAesKey(); + const { wrappedKey, mekVersion } = await keyProvider.wrapKey(dek); + + const wrappedDek: WrappedDek = { + dekId, + wrappedKey, + mekVersion, + createdAt: new Date().toISOString(), + }; + await dekStore.put(wrappedDek); + + cache.set(dekId, dek); + return dek; +} + +/** + * Re-wrap all DEKs after MEK rotation. + * + * Reads each wrapped DEK, unwraps with old MEK, wraps with new MEK, stores updated. + */ +export async function rewrapAllDeks( + oldKeyProvider: KeyProvider, + newKeyProvider: KeyProvider, + dekStore: DekStore, + cache: DekCache, + onProgress?: (completed: number, total: number) => void +): Promise { + const dekIds = await dekStore.listIds(); + let completed = 0; + + for (const dekId of dekIds) { + const stored = await dekStore.get(dekId); + if (!stored) continue; + + // Unwrap with old MEK + const rawDek = await oldKeyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); + + // Wrap with new MEK + const { wrappedKey, mekVersion } = await newKeyProvider.wrapKey(rawDek); + + // Store updated wrapped DEK + const updated: WrappedDek = { + dekId, + wrappedKey, + mekVersion, + createdAt: stored.createdAt, + }; + await dekStore.put(updated); + + // Invalidate cache entry so it gets re-unwrapped with new MEK next time + cache.invalidate(dekId); + + completed++; + onProgress?.(completed, dekIds.length); + } + + return completed; +} diff --git a/vendor/bytelyst/field-encrypt/src/field-encryptor.ts b/vendor/bytelyst/field-encrypt/src/field-encryptor.ts new file mode 100644 index 0000000..50f7e32 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/field-encryptor.ts @@ -0,0 +1,227 @@ +/** + * @bytelyst/field-encrypt — FieldEncryptor + * + * Main API — wires key provider + DEK store + cache + AES-GCM. + * Product backends create a singleton via createFieldEncryptor(). + */ + +import type { + EncryptedField, + FieldEncryptContext, + FieldEncryptorConfig, + KeyProvider, + DekStore, +} from './types.js'; +import { encryptField, decryptField } from './aes-gcm.js'; +import { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; +import { DekCache } from './key-cache.js'; +import { MemoryDekStore } from './dek-store-memory.js'; +import { MemoryKeyProvider } from './key-provider-memory.js'; +import { EnvKeyProvider } from './key-provider-env.js'; +import { AkvKeyProvider } from './key-provider-akv.js'; +import { isEncryptedField } from './guards.js'; + +export class FieldEncryptor { + private readonly keyProvider: KeyProvider; + private readonly dekStore: DekStore; + private readonly cache: DekCache; + + constructor(config: FieldEncryptorConfig) { + this.keyProvider = resolveKeyProvider(config); + this.dekStore = config.dekStore ?? new MemoryDekStore(); + this.cache = new DekCache( + config.dekCacheTtlMs ?? 15 * 60 * 1000, + config.dekCacheMaxSize ?? 1000 + ); + } + + /** + * Encrypt a plaintext string. + * + * Automatically gets or creates a DEK scoped to the userId + context. + */ + async encrypt(plaintext: string, ctx: FieldEncryptContext): Promise { + const dekId = buildDekId(ctx.userId, ctx.context); + const aad = `${ctx.userId}:${ctx.context}`; + const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); + return encryptField(plaintext, dek, dekId, aad); + } + + /** + * Decrypt an EncryptedField back to plaintext. + */ + async decrypt(field: EncryptedField, ctx: FieldEncryptContext): Promise { + const aad = `${ctx.userId}:${ctx.context}`; + const dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); + return decryptField(field, dek, aad); + } + + /** + * Encrypt multiple fields in a single call (optimized — single DEK lookup). + */ + async encryptBatch(plaintexts: string[], ctx: FieldEncryptContext): Promise { + const dekId = buildDekId(ctx.userId, ctx.context); + const aad = `${ctx.userId}:${ctx.context}`; + const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); + return plaintexts.map(pt => encryptField(pt, dek, dekId, aad)); + } + + /** + * Decrypt multiple EncryptedFields in a single call. + * + * Groups by dekId for efficient DEK lookup. + */ + async decryptBatch(fields: EncryptedField[], ctx: FieldEncryptContext): Promise { + const aad = `${ctx.userId}:${ctx.context}`; + const dekMap = new Map(); + + const results: string[] = []; + for (const field of fields) { + let dek = dekMap.get(field.dekId); + if (!dek) { + dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); + dekMap.set(field.dekId, dek); + } + results.push(decryptField(field, dek, aad)); + } + + return results; + } + + /** + * Check if a value is an EncryptedField. + */ + isEncrypted(value: unknown): value is EncryptedField { + return isEncryptedField(value); + } + + /** + * Re-wrap all DEKs after MEK rotation. + */ + async rewrapDeks( + newKeyProvider: KeyProvider, + onProgress?: (completed: number, total: number) => void + ): Promise { + return rewrapAllDeks(this.keyProvider, newKeyProvider, this.dekStore, this.cache, onProgress); + } + + /** DEK cache hit rate (0-100). */ + get cacheHitRate(): number { + return this.cache.hitRate; + } + + /** Number of cached DEKs. */ + get cacheSize(): number { + return this.cache.size; + } + + /** Reset cache statistics. */ + resetCacheStats(): void { + this.cache.resetStats(); + } + + /** Clear DEK cache (e.g., on shutdown). */ + clearCache(): void { + this.cache.clear(); + } +} + +function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { + switch (config.keyProvider) { + case 'memory': + return new MemoryKeyProvider(); + + case 'env': { + const key = config.encryptionKey; + if (!key) { + throw new Error('FieldEncryptor: "env" key provider requires encryptionKey (hex string)'); + } + return new EnvKeyProvider(key); + } + + case 'akv': { + if (!config.keyVaultUrl) { + throw new Error('FieldEncryptor: "akv" key provider requires keyVaultUrl'); + } + if (!config.mekName) { + throw new Error('FieldEncryptor: "akv" key provider requires mekName'); + } + return new AkvKeyProvider(config.keyVaultUrl, config.mekName); + } + + default: + throw new Error(`FieldEncryptor: unknown key provider "${config.keyProvider}"`); + } +} + +/** + * No-op encryptor — stores/returns plaintext unchanged. + * + * Returned by createFieldEncryptor({ enabled: false, ... }). + * All repositories continue calling encrypt()/decrypt() without branching. + */ +export class NullFieldEncryptor extends FieldEncryptor { + constructor() { + // Use memory provider — it will never be called, but satisfies the constructor + super({ keyProvider: 'memory' }); + } + + override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise { + // Return a sentinel object that looks encrypted but stores plaintext + return { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: plaintext, + iv: 'disabled', + tag: 'disabled', + dekId: 'disabled', + }; + } + + override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise { + // If encryption was disabled, ct contains the plaintext directly + return field.ct; + } + + override async encryptBatch( + plaintexts: string[], + ctx: FieldEncryptContext + ): Promise { + return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx))); + } + + override async decryptBatch( + fields: EncryptedField[], + ctx: FieldEncryptContext + ): Promise { + return Promise.all(fields.map(f => this.decrypt(f, ctx))); + } +} + +/** + * Create a FieldEncryptor instance. + * + * Typical usage (one per backend service): + * ```typescript + * const encryptor = createFieldEncryptor({ + * keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER ?? 'memory', + * mekName: 'lysnr-mek', + * keyVaultUrl: config.AZURE_KEYVAULT_URL, + * }); + * ``` + * + * To disable encryption globally or per-product: + * ```typescript + * const encryptor = createFieldEncryptor({ + * enabled: false, // ← no-op passthrough + * keyProvider: 'memory', + * }); + * ``` + */ +export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor { + if (config.enabled === false) { + return new NullFieldEncryptor(); + } + return new FieldEncryptor(config); +} diff --git a/vendor/bytelyst/field-encrypt/src/guards.ts b/vendor/bytelyst/field-encrypt/src/guards.ts new file mode 100644 index 0000000..f8c63b8 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/guards.ts @@ -0,0 +1,27 @@ +/** + * @bytelyst/field-encrypt — Type guards + * + * Utility to detect encrypted vs plaintext fields during migration. + */ + +import type { EncryptedField } from './types.js'; + +/** + * Check if a value is an EncryptedField. + * + * Use this in repositories to handle both encrypted and plaintext fields + * during the migration period. + */ +export function isEncryptedField(value: unknown): value is EncryptedField { + return ( + typeof value === 'object' && + value !== null && + '__encrypted' in value && + (value as Record).__encrypted === true && + 'v' in value && + 'ct' in value && + 'iv' in value && + 'tag' in value && + 'dekId' in value + ); +} diff --git a/vendor/bytelyst/field-encrypt/src/index.test.ts b/vendor/bytelyst/field-encrypt/src/index.test.ts new file mode 100644 index 0000000..bff9494 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/index.test.ts @@ -0,0 +1,608 @@ +/** + * @bytelyst/field-encrypt — Tests + * + * ~35 tests covering AES-GCM, key providers, envelope, cache, + * field encryptor factory, type guards, and migration. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { + createFieldEncryptor, + FieldEncryptor, + NullFieldEncryptor, + isEncryptedField, + encryptField, + decryptField, + generateAesKey, + buildDekId, + getOrCreateDek, + rewrapAllDeks, + DekCache, + MemoryDekStore, + CosmosDekStore, + MemoryKeyProvider, + EnvKeyProvider, + migrateDocuments, +} from './index.js'; +import type { EncryptedField, FieldEncryptContext } from './types.js'; + +// ── AES-256-GCM ───────────────────────────────────── + +describe('aes-gcm', () => { + const key = generateAesKey(); + const dekId = 'dek_test_ctx'; + + it('encrypt → decrypt roundtrip', () => { + const plaintext = 'Hello, sensitive data!'; + const encrypted = encryptField(plaintext, key, dekId); + const decrypted = decryptField(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + + it('encrypt → decrypt with AAD', () => { + const plaintext = 'With AAD'; + const aad = 'user_123:transcripts'; + const encrypted = encryptField(plaintext, key, dekId, aad); + const decrypted = decryptField(encrypted, key, aad); + expect(decrypted).toBe(plaintext); + }); + + it('rejects decryption with wrong AAD', () => { + const encrypted = encryptField('secret', key, dekId, 'correct_aad'); + expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow(); + }); + + it('rejects decryption with tampered ciphertext', () => { + const encrypted = encryptField('secret', key, dekId); + const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' }; + expect(() => decryptField(tampered, key)).toThrow(); + }); + + it('rejects decryption with tampered auth tag', () => { + const encrypted = encryptField('secret', key, dekId); + const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) }; + expect(() => decryptField(tampered, key)).toThrow(); + }); + + it('handles empty string', () => { + const encrypted = encryptField('', key, dekId); + const decrypted = decryptField(encrypted, key); + expect(decrypted).toBe(''); + }); + + it('handles unicode content', () => { + const plaintext = '日本語テスト 🔐 Ñoño'; + const encrypted = encryptField(plaintext, key, dekId); + const decrypted = decryptField(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + + it('handles large payload (100 KB)', () => { + const plaintext = 'x'.repeat(100_000); + const encrypted = encryptField(plaintext, key, dekId); + const decrypted = decryptField(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + + it('rejects wrong key size', () => { + const shortKey = randomBytes(16); + expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/); + }); + + it('produces correct EncryptedField shape', () => { + const encrypted = encryptField('test', key, dekId); + expect(encrypted.__encrypted).toBe(true); + expect(encrypted.v).toBe(1); + expect(encrypted.alg).toBe('aes-256-gcm'); + expect(encrypted.dekId).toBe(dekId); + expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars + expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars + expect(encrypted.ct.length).toBeGreaterThan(0); + }); + + it('generates unique IVs per encryption', () => { + const e1 = encryptField('same', key, dekId); + const e2 = encryptField('same', key, dekId); + expect(e1.iv).not.toBe(e2.iv); + expect(e1.ct).not.toBe(e2.ct); + }); +}); + +// ── Type guard ────────────────────────────────────── + +describe('isEncryptedField', () => { + it('returns true for valid EncryptedField', () => { + const field: EncryptedField = { + __encrypted: true, + v: 1, + alg: 'aes-256-gcm', + ct: 'abc', + iv: '012', + tag: '345', + dekId: 'dek_1', + }; + expect(isEncryptedField(field)).toBe(true); + }); + + it('returns false for string', () => { + expect(isEncryptedField('hello')).toBe(false); + }); + + it('returns false for null', () => { + expect(isEncryptedField(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isEncryptedField(undefined)).toBe(false); + }); + + it('returns false for object without __encrypted', () => { + expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false); + }); + + it('returns false for object with __encrypted: false', () => { + expect( + isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' }) + ).toBe(false); + }); +}); + +// ── Key providers ─────────────────────────────────── + +describe('MemoryKeyProvider', () => { + it('wrap → unwrap roundtrip', async () => { + const provider = new MemoryKeyProvider(); + const dek = generateAesKey(); + const { wrappedKey, mekVersion } = await provider.wrapKey(dek); + const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); + expect(unwrapped).toEqual(dek); + }); +}); + +describe('EnvKeyProvider', () => { + it('wrap → unwrap roundtrip with 64-char hex key', async () => { + const hexKey = randomBytes(32).toString('hex'); + const provider = new EnvKeyProvider(hexKey); + const dek = generateAesKey(); + const { wrappedKey, mekVersion } = await provider.wrapKey(dek); + const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); + expect(unwrapped).toEqual(dek); + }); + + it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => { + const provider = new EnvKeyProvider('my-dev-secret-key'); + const dek = generateAesKey(); + const { wrappedKey, mekVersion } = await provider.wrapKey(dek); + const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); + expect(unwrapped).toEqual(dek); + }); + + it('throws on empty key', () => { + expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/); + }); +}); + +// ── DEK cache ─────────────────────────────────────── + +describe('DekCache', () => { + let cache: DekCache; + + beforeEach(() => { + cache = new DekCache(1000, 3); // 1s TTL, max 3 entries + }); + + it('get returns null on miss', () => { + expect(cache.get('nonexistent')).toBeNull(); + }); + + it('set + get roundtrip', () => { + const key = generateAesKey(); + cache.set('dek_1', key); + expect(cache.get('dek_1')).toEqual(key); + }); + + it('expires entries after TTL', async () => { + const shortCache = new DekCache(50, 100); // 50ms TTL + const key = generateAesKey(); + shortCache.set('dek_1', key); + expect(shortCache.get('dek_1')).toEqual(key); + await new Promise(r => setTimeout(r, 60)); + expect(shortCache.get('dek_1')).toBeNull(); + }); + + it('evicts oldest on max size', () => { + cache.set('a', generateAesKey()); + cache.set('b', generateAesKey()); + cache.set('c', generateAesKey()); + // At max size (3), adding 'd' should evict 'a' + cache.set('d', generateAesKey()); + expect(cache.get('a')).toBeNull(); + expect(cache.get('d')).not.toBeNull(); + }); + + it('invalidate removes specific entry', () => { + cache.set('dek_1', generateAesKey()); + cache.invalidate('dek_1'); + expect(cache.get('dek_1')).toBeNull(); + }); + + it('tracks hit rate', () => { + cache.set('dek_1', generateAesKey()); + cache.recordHit(); + cache.recordHit(); + cache.recordMiss(); + expect(cache.hitRate).toBe(67); // 2/3 = 67% + }); +}); + +// ── Envelope ──────────────────────────────────────── + +describe('envelope', () => { + it('buildDekId produces correct format', () => { + expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts'); + }); + + it('getOrCreateDek creates and caches a new DEK', async () => { + const provider = new MemoryKeyProvider(); + const store = new MemoryDekStore(); + const cache = new DekCache(); + + const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache); + expect(dek).toHaveLength(32); + + // Should be in store + const stored = await store.get('dek_u1_ctx'); + expect(stored).not.toBeNull(); + + // Should be cached — second call should return same key + const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache); + expect(dek2).toEqual(dek); + }); + + it('rewrapAllDeks re-wraps with new provider', async () => { + const oldProvider = new MemoryKeyProvider(undefined, 'old-v1'); + const newProvider = new MemoryKeyProvider(undefined, 'new-v1'); + const store = new MemoryDekStore(); + const cache = new DekCache(); + + // Create 3 DEKs with old provider + await getOrCreateDek('dek_1', oldProvider, store, cache); + await getOrCreateDek('dek_2', oldProvider, store, cache); + await getOrCreateDek('dek_3', oldProvider, store, cache); + + // Re-wrap + const count = await rewrapAllDeks(oldProvider, newProvider, store, cache); + expect(count).toBe(3); + + // Verify new provider can unwrap + const stored = await store.get('dek_1'); + expect(stored).not.toBeNull(); + expect(stored!.mekVersion).toBe('new-v1'); + + const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion); + expect(unwrapped).toHaveLength(32); + }); +}); + +// ── FieldEncryptor (integration) ──────────────────── + +describe('FieldEncryptor', () => { + let encryptor: FieldEncryptor; + const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' }; + + beforeEach(() => { + encryptor = createFieldEncryptor({ keyProvider: 'memory' }); + }); + + it('encrypt → decrypt roundtrip', async () => { + const encrypted = await encryptor.encrypt('Hello World', ctx); + expect(encrypted.__encrypted).toBe(true); + const decrypted = await encryptor.decrypt(encrypted, ctx); + expect(decrypted).toBe('Hello World'); + }); + + it('encryptBatch → decryptBatch roundtrip', async () => { + const plaintexts = ['one', 'two', 'three']; + const encrypted = await encryptor.encryptBatch(plaintexts, ctx); + expect(encrypted).toHaveLength(3); + const decrypted = await encryptor.decryptBatch(encrypted, ctx); + expect(decrypted).toEqual(plaintexts); + }); + + it('isEncrypted works via encryptor', async () => { + const encrypted = await encryptor.encrypt('test', ctx); + expect(encryptor.isEncrypted(encrypted)).toBe(true); + expect(encryptor.isEncrypted('plaintext')).toBe(false); + }); + + it('different users get different DEKs', async () => { + const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' }; + const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' }; + + const e1 = await encryptor.encrypt('same text', ctx1); + const e2 = await encryptor.encrypt('same text', ctx2); + + expect(e1.dekId).not.toBe(e2.dekId); + expect(e1.ct).not.toBe(e2.ct); + }); + + it('JSON-serialized array encryption roundtrip', async () => { + const transcript = [ + { role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' }, + { role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' }, + ]; + const serialized = JSON.stringify(transcript); + const encrypted = await encryptor.encrypt(serialized, ctx); + const decrypted = await encryptor.decrypt(encrypted, ctx); + expect(JSON.parse(decrypted)).toEqual(transcript); + }); +}); + +// ── Factory config validation ─────────────────────── + +describe('createFieldEncryptor config', () => { + it('throws on unknown provider', () => { + expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/); + }); + + it('throws on env provider without key', () => { + expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/); + }); + + it('throws on akv provider without vaultUrl', () => { + expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow( + /keyVaultUrl/ + ); + }); + + it('throws on akv provider without mekName', () => { + expect(() => + createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' }) + ).toThrow(/mekName/); + }); + + it('env provider works with hex key', async () => { + const hexKey = randomBytes(32).toString('hex'); + const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey }); + const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; + const encrypted = await enc.encrypt('secret', ctx); + const decrypted = await enc.decrypt(encrypted, ctx); + expect(decrypted).toBe('secret'); + }); +}); + +// ── Encryption toggle (enabled/disabled) ──────────── + +describe('enabled: false (NullFieldEncryptor)', () => { + const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; + + it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + expect(enc).toBeInstanceOf(NullFieldEncryptor); + }); + + it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => { + const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' }); + expect(enc).not.toBeInstanceOf(NullFieldEncryptor); + expect(enc).toBeInstanceOf(FieldEncryptor); + }); + + it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => { + const enc = createFieldEncryptor({ keyProvider: 'memory' }); + expect(enc).not.toBeInstanceOf(NullFieldEncryptor); + }); + + it('encrypt returns sentinel with plaintext in ct field', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const result = await enc.encrypt('hello world', ctx); + expect(result.__encrypted).toBe(true); + expect(result.ct).toBe('hello world'); + expect(result.iv).toBe('disabled'); + expect(result.tag).toBe('disabled'); + expect(result.dekId).toBe('disabled'); + }); + + it('decrypt returns plaintext from ct field', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const encrypted = await enc.encrypt('secret', ctx); + const decrypted = await enc.decrypt(encrypted, ctx); + expect(decrypted).toBe('secret'); + }); + + it('encryptBatch returns sentinels for all items', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const results = await enc.encryptBatch(['a', 'b', 'c'], ctx); + expect(results).toHaveLength(3); + expect(results[0].ct).toBe('a'); + expect(results[1].ct).toBe('b'); + expect(results[2].ct).toBe('c'); + }); + + it('decryptBatch returns plaintexts from ct fields', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const encrypted = await enc.encryptBatch(['x', 'y'], ctx); + const decrypted = await enc.decryptBatch(encrypted, ctx); + expect(decrypted).toEqual(['x', 'y']); + }); + + it('isEncrypted returns true for disabled sentinel', async () => { + const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); + const result = await enc.encrypt('test', ctx); + expect(enc.isEncrypted(result)).toBe(true); + }); +}); + +// ── Migration ─────────────────────────────────────── + +describe('migrateDocuments', () => { + it('encrypts plaintext fields and skips already-encrypted', async () => { + const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); + const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; + + const alreadyEncrypted = await encryptor.encrypt('old', ctx); + const docs = [ + { id: '1', body: 'plaintext note' }, + { id: '2', body: alreadyEncrypted }, + { id: '3', body: 'another note' }, + { id: '4', body: null }, + ]; + + const written: Array<{ id: string; body: EncryptedField }> = []; + + const result = await migrateDocuments({ + fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), + getId: doc => doc.id, + getField: doc => doc.body, + encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), + writeBack: async (doc, encrypted) => { + written.push({ id: doc.id, body: encrypted }); + }, + batchSize: 10, + }); + + expect(result.scanned).toBe(4); + expect(result.encrypted).toBe(2); // id 1 + id 3 + expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null) + expect(result.errors).toBe(0); + expect(written).toHaveLength(2); + }); + + it('dry run does not write', async () => { + const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); + const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; + + const docs = [{ id: '1', body: 'plaintext' }]; + let writeCount = 0; + + const result = await migrateDocuments({ + fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), + getId: doc => doc.id, + getField: doc => doc.body, + encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), + writeBack: async () => { + writeCount++; + }, + dryRun: true, + }); + + expect(result.encrypted).toBe(1); + expect(writeCount).toBe(0); + }); +}); + +// ── CosmosDekStore ────────────────────────────────── + +describe('CosmosDekStore', () => { + function createMockContainer() { + const docs = new Map>(); + + const container = { + item: (id: string, _pk: string) => ({ + read: async () => { + const resource = docs.get(id) as T | undefined; + if (!resource) { + const err = new Error('Not found') as Error & { code: number }; + err.code = 404; + throw err; + } + return { resource }; + }, + delete: async () => { + if (!docs.has(id)) { + const err = new Error('Not found') as Error & { code: number }; + err.code = 404; + throw err; + } + docs.delete(id); + }, + }), + items: { + upsert: async (doc: Record) => { + docs.set(doc.id as string, doc); + }, + query: (_sql: string) => ({ + fetchAll: async () => ({ + resources: [...docs.values()].map(d => ({ dekId: d.dekId })), + }), + }), + }, + }; + return container as unknown as import('@azure/cosmos').Container; + } + + it('put and get a DEK', async () => { + const store = new CosmosDekStore(createMockContainer()); + const dek = { + dekId: 'dek_test', + wrappedKey: 'aabbcc', + mekVersion: 'v1', + createdAt: '2026-01-01T00:00:00Z', + }; + await store.put(dek); + const got = await store.get('dek_test'); + expect(got).toEqual(dek); + }); + + it('get returns null for missing DEK', async () => { + const store = new CosmosDekStore(createMockContainer()); + const got = await store.get('nonexistent'); + expect(got).toBeNull(); + }); + + it('listIds returns all stored DEK IDs', async () => { + const store = new CosmosDekStore(createMockContainer()); + await store.put({ + dekId: 'dek_a', + wrappedKey: '11', + mekVersion: 'v1', + createdAt: '2026-01-01T00:00:00Z', + }); + await store.put({ + dekId: 'dek_b', + wrappedKey: '22', + mekVersion: 'v1', + createdAt: '2026-01-01T00:00:00Z', + }); + const ids = await store.listIds(); + expect(ids).toContain('dek_a'); + expect(ids).toContain('dek_b'); + expect(ids.length).toBe(2); + }); + + it('delete removes a DEK', async () => { + const store = new CosmosDekStore(createMockContainer()); + await store.put({ + dekId: 'dek_del', + wrappedKey: '33', + mekVersion: 'v1', + createdAt: '2026-01-01T00:00:00Z', + }); + await store.delete('dek_del'); + expect(await store.get('dek_del')).toBeNull(); + }); + + it('delete is idempotent (no error on missing)', async () => { + const store = new CosmosDekStore(createMockContainer()); + await expect(store.delete('nonexistent')).resolves.toBeUndefined(); + }); + + it('put overwrites existing DEK (upsert)', async () => { + const store = new CosmosDekStore(createMockContainer()); + await store.put({ + dekId: 'dek_up', + wrappedKey: 'old', + mekVersion: 'v1', + createdAt: '2026-01-01T00:00:00Z', + }); + await store.put({ + dekId: 'dek_up', + wrappedKey: 'new', + mekVersion: 'v2', + createdAt: '2026-01-01T00:00:00Z', + }); + const got = await store.get('dek_up'); + expect(got?.wrappedKey).toBe('new'); + expect(got?.mekVersion).toBe('v2'); + }); +}); diff --git a/vendor/bytelyst/field-encrypt/src/index.ts b/vendor/bytelyst/field-encrypt/src/index.ts new file mode 100644 index 0000000..7ea2e0d --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/index.ts @@ -0,0 +1,58 @@ +/** + * @bytelyst/field-encrypt + * + * Application-layer field encryption for ByteLyst ecosystem. + * AES-256-GCM with envelope encryption (MEK → DEK). + * + * @example + * ```typescript + * import { createFieldEncryptor } from '@bytelyst/field-encrypt'; + * + * const encryptor = createFieldEncryptor({ + * keyProvider: 'memory', // 'akv' | 'env' | 'memory' + * }); + * + * const encrypted = await encryptor.encrypt('sensitive data', { + * userId: 'user_123', + * context: 'transcripts', + * }); + * + * const plaintext = await encryptor.decrypt(encrypted, { + * userId: 'user_123', + * context: 'transcripts', + * }); + * ``` + */ + +// ── Main API ──────────────────────────────────────── +export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js'; + +// ── Type guards ───────────────────────────────────── +export { isEncryptedField } from './guards.js'; + +// ── Types ─────────────────────────────────────────── +export type { + EncryptedField, + WrappedDek, + FieldEncryptContext, + FieldEncryptorConfig, + KeyProvider, + KeyProviderType, + DekStore, +} from './types.js'; + +// ── Low-level (for custom integrations) ───────────── +export { encryptField, decryptField, generateAesKey } from './aes-gcm.js'; +export { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; +export { DekCache } from './key-cache.js'; +export { MemoryDekStore } from './dek-store-memory.js'; +export { CosmosDekStore } from './dek-store-cosmos.js'; + +// ── Key providers (for direct use / testing) ──────── +export { MemoryKeyProvider } from './key-provider-memory.js'; +export { EnvKeyProvider } from './key-provider-env.js'; +export { AkvKeyProvider } from './key-provider-akv.js'; + +// ── Migration ─────────────────────────────────────── +export { migrateDocuments } from './migration.js'; +export type { MigrationResult, MigrateDocumentsOptions } from './migration.js'; diff --git a/vendor/bytelyst/field-encrypt/src/key-cache.ts b/vendor/bytelyst/field-encrypt/src/key-cache.ts new file mode 100644 index 0000000..8668252 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/key-cache.ts @@ -0,0 +1,94 @@ +/** + * @bytelyst/field-encrypt — DEK cache + * + * In-memory LRU cache with TTL for unwrapped DEKs. + * Avoids repeated AKV round-trips on every encrypt/decrypt. + */ + +interface CacheEntry { + key: Buffer; + expiresAt: number; +} + +export class DekCache { + private readonly cache = new Map(); + private readonly ttlMs: number; + private readonly maxSize: number; + + constructor(ttlMs: number = 15 * 60 * 1000, maxSize: number = 1000) { + this.ttlMs = ttlMs; + this.maxSize = maxSize; + } + + /** Get an unwrapped DEK from cache. Returns null on miss or expiry. */ + get(dekId: string): Buffer | null { + const entry = this.cache.get(dekId); + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.cache.delete(dekId); + return null; + } + + // Move to end (LRU refresh) + this.cache.delete(dekId); + this.cache.set(dekId, entry); + return entry.key; + } + + /** Store an unwrapped DEK in cache. */ + set(dekId: string, key: Buffer): void { + // Evict oldest if at max size + if (this.cache.size >= this.maxSize && !this.cache.has(dekId)) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(dekId, { + key, + expiresAt: Date.now() + this.ttlMs, + }); + } + + /** Invalidate a specific DEK (e.g., after rotation). */ + invalidate(dekId: string): void { + this.cache.delete(dekId); + } + + /** Clear all cached DEKs. */ + clear(): void { + this.cache.clear(); + } + + /** Current cache size. */ + get size(): number { + return this.cache.size; + } + + /** Cache hit rate stats. */ + private _hits = 0; + private _misses = 0; + + /** Record a cache hit (called internally). */ + recordHit(): void { + this._hits++; + } + /** Record a cache miss (called internally). */ + recordMiss(): void { + this._misses++; + } + + /** Get hit rate as a percentage (0-100). */ + get hitRate(): number { + const total = this._hits + this._misses; + return total === 0 ? 0 : Math.round((this._hits / total) * 100); + } + + /** Reset stats counters. */ + resetStats(): void { + this._hits = 0; + this._misses = 0; + } +} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts b/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts new file mode 100644 index 0000000..e2a9ff5 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts @@ -0,0 +1,68 @@ +/** + * @bytelyst/field-encrypt — Azure Key Vault key provider + * + * Production provider — uses AKV RSA keys for DEK wrapping. + * Requires @azure/keyvault-keys and @azure/identity as peer deps. + */ + +import type { KeyProvider } from './types.js'; + +/** + * Azure Key Vault key provider. + * + * Uses RSA-OAEP wrapping — the MEK never leaves AKV. + * Requires: + * - @azure/keyvault-keys (CryptographyClient) + * - @azure/identity (DefaultAzureCredential) + * - AKV RBAC: Key Vault Crypto User role on the managed identity + */ +export class AkvKeyProvider implements KeyProvider { + private readonly vaultUrl: string; + private readonly mekName: string; + private cryptoClient: unknown | null = null; + + constructor(vaultUrl: string, mekName: string) { + if (!vaultUrl) throw new Error('AkvKeyProvider: vaultUrl is required'); + if (!mekName) throw new Error('AkvKeyProvider: mekName is required'); + this.vaultUrl = vaultUrl; + this.mekName = mekName; + } + + private async getClient(): Promise<{ + wrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; + unwrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; + }> { + if (this.cryptoClient) return this.cryptoClient as never; + + // Dynamic import to keep peer deps optional + const { KeyClient, CryptographyClient } = await import('@azure/keyvault-keys'); + const { DefaultAzureCredential } = await import('@azure/identity'); + + const credential = new DefaultAzureCredential(); + const keyClient = new KeyClient(this.vaultUrl, credential); + const key = await keyClient.getKey(this.mekName); + + if (!key.id) { + throw new Error(`AkvKeyProvider: MEK '${this.mekName}' not found in ${this.vaultUrl}`); + } + + this.cryptoClient = new CryptographyClient(key.id, credential); + return this.cryptoClient as never; + } + + async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { + const client = await this.getClient(); + const result = await client.wrapKey('RSA-OAEP-256', new Uint8Array(dek)); + return { + wrappedKey: Buffer.from(result.result).toString('hex'), + mekVersion: this.mekName, + }; + } + + async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { + const client = await this.getClient(); + const wrappedBytes = new Uint8Array(Buffer.from(wrappedKeyHex, 'hex')); + const result = await client.unwrapKey('RSA-OAEP-256', wrappedBytes); + return Buffer.from(result.result); + } +} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-env.ts b/vendor/bytelyst/field-encrypt/src/key-provider-env.ts new file mode 100644 index 0000000..4b7de08 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/key-provider-env.ts @@ -0,0 +1,62 @@ +/** + * @bytelyst/field-encrypt — Environment variable key provider + * + * For dev/staging — uses a hex-encoded symmetric key from an env var. + * Matches the existing MFA pattern (AUTH_TOTP_ENCRYPTION_KEY). + * + * Wrapping uses AES-256-GCM with the env key as MEK. + */ + +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; +import type { KeyProvider } from './types.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; + +export class EnvKeyProvider implements KeyProvider { + private readonly mek: Buffer; + private readonly version: string; + + /** + * @param keyHex - Hex-encoded 32-byte key (64 hex chars). + * If shorter, it will be SHA-256 hashed to derive a 32-byte key. + */ + constructor(keyHex: string) { + if (!keyHex || keyHex.length === 0) { + throw new Error('EnvKeyProvider: encryption key must not be empty'); + } + + if (keyHex.length === 64) { + this.mek = Buffer.from(keyHex, 'hex'); + } else { + // Hash to 32 bytes — same approach as existing MFA encryption + this.mek = createHash('sha256').update(keyHex).digest(); + } + + this.version = 'env-v1'; + } + + async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, this.mek, iv); + let encrypted = cipher.update(dek); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const tag = cipher.getAuthTag(); + + const wrapped = Buffer.concat([iv, tag, encrypted]); + return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; + } + + async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { + const wrapped = Buffer.from(wrappedKeyHex, 'hex'); + const iv = wrapped.subarray(0, IV_BYTES); + const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); + const ciphertext = wrapped.subarray(IV_BYTES + 16); + + const decipher = createDecipheriv(ALGORITHM, this.mek, iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(ciphertext); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted; + } +} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts b/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts new file mode 100644 index 0000000..e586798 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts @@ -0,0 +1,48 @@ +/** + * @bytelyst/field-encrypt — In-memory key provider + * + * For unit tests — no external dependencies. + * Generates a random MEK on instantiation. Wrapping is just XOR for simplicity in tests, + * but uses AES-256-GCM to match production semantics. + */ + +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import type { KeyProvider } from './types.js'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; + +export class MemoryKeyProvider implements KeyProvider { + private readonly mek: Buffer; + private readonly version: string; + + constructor(mek?: Buffer, version?: string) { + this.mek = mek ?? randomBytes(32); + this.version = version ?? 'memory-v1'; + } + + async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, this.mek, iv); + let encrypted = cipher.update(dek); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const tag = cipher.getAuthTag(); + + // Format: iv (12) + tag (16) + ciphertext + const wrapped = Buffer.concat([iv, tag, encrypted]); + return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; + } + + async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { + const wrapped = Buffer.from(wrappedKeyHex, 'hex'); + const iv = wrapped.subarray(0, IV_BYTES); + const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); + const ciphertext = wrapped.subarray(IV_BYTES + 16); + + const decipher = createDecipheriv(ALGORITHM, this.mek, iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(ciphertext); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted; + } +} diff --git a/vendor/bytelyst/field-encrypt/src/migration.ts b/vendor/bytelyst/field-encrypt/src/migration.ts new file mode 100644 index 0000000..978c4ac --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/migration.ts @@ -0,0 +1,110 @@ +/** + * @bytelyst/field-encrypt — Migration helpers + * + * Utilities for encrypting existing plaintext fields in-place. + * Idempotent — skips already-encrypted fields via __encrypted sentinel. + */ + +import type { EncryptedField } from './types.js'; +import { isEncryptedField } from './guards.js'; + +/** Result of a migration run. */ +export interface MigrationResult { + /** Total documents scanned. */ + scanned: number; + /** Documents encrypted in this run. */ + encrypted: number; + /** Documents skipped (already encrypted). */ + skipped: number; + /** Documents that failed to encrypt. */ + errors: number; + /** Error details (first 10). */ + errorDetails: Array<{ id: string; error: string }>; +} + +/** Options for migrateDocuments(). */ +export interface MigrateDocumentsOptions { + /** Fetch a batch of documents. Return empty array when done. */ + fetchBatch: (offset: number, batchSize: number) => Promise; + /** Get the document ID for logging. */ + getId: (doc: T) => string; + /** Get the field value to check/encrypt. */ + getField: (doc: T) => unknown; + /** Encrypt the plaintext value. Returns the EncryptedField. */ + encryptValue: (plaintext: string, doc: T) => Promise; + /** Write the encrypted value back to the store. */ + writeBack: (doc: T, encrypted: EncryptedField) => Promise; + /** Batch size (default: 100). */ + batchSize?: number; + /** If true, don't write — just count. */ + dryRun?: boolean; + /** Progress callback. */ + onProgress?: (result: MigrationResult) => void; +} + +/** + * Migrate plaintext fields to encrypted fields in batches. + * + * Idempotent: skips documents where the field is already an EncryptedField. + */ +export async function migrateDocuments( + options: MigrateDocumentsOptions +): Promise { + const batchSize = options.batchSize ?? 100; + const result: MigrationResult = { + scanned: 0, + encrypted: 0, + skipped: 0, + errors: 0, + errorDetails: [], + }; + + let offset = 0; + let batch: T[]; + + do { + batch = await options.fetchBatch(offset, batchSize); + + for (const doc of batch) { + result.scanned++; + const fieldValue = options.getField(doc); + + // Skip already-encrypted + if (isEncryptedField(fieldValue)) { + result.skipped++; + continue; + } + + // Skip null/undefined + if (fieldValue == null) { + result.skipped++; + continue; + } + + const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue); + + try { + const encrypted = await options.encryptValue(plaintext, doc); + + if (!options.dryRun) { + await options.writeBack(doc, encrypted); + } + + result.encrypted++; + } catch (err) { + result.errors++; + if (result.errorDetails.length < 10) { + result.errorDetails.push({ + id: options.getId(doc), + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + offset += batch.length; + options.onProgress?.(result); + } while (batch.length === batchSize); + + return result; +} diff --git a/vendor/bytelyst/field-encrypt/src/types.ts b/vendor/bytelyst/field-encrypt/src/types.ts new file mode 100644 index 0000000..2273fcd --- /dev/null +++ b/vendor/bytelyst/field-encrypt/src/types.ts @@ -0,0 +1,90 @@ +/** + * @bytelyst/field-encrypt — Types + * + * Core type definitions for field-level encryption. + */ + +/** Encrypted field stored in Cosmos DB or SQLite. */ +export interface EncryptedField { + /** Sentinel — always true for encrypted fields. */ + readonly __encrypted: true; + /** Schema version for future algorithm changes. */ + readonly v: 1; + /** Algorithm identifier. */ + readonly alg: 'aes-256-gcm'; + /** Ciphertext (hex-encoded). */ + readonly ct: string; + /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ + readonly iv: string; + /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ + readonly tag: string; + /** DEK identifier — identifies which key to unwrap for decryption. */ + readonly dekId: string; +} + +/** Wrapped DEK stored alongside data (e.g., in a `_encryption_keys` Cosmos container). */ +export interface WrappedDek { + /** Unique DEK identifier, e.g. `dek_user123_transcripts`. */ + readonly dekId: string; + /** Wrapped (encrypted) DEK bytes (hex-encoded). */ + readonly wrappedKey: string; + /** MEK name/version used to wrap this DEK. */ + readonly mekVersion: string; + /** ISO 8601 creation timestamp. */ + readonly createdAt: string; +} + +/** Options for encrypt/decrypt operations. */ +export interface FieldEncryptContext { + /** Scope for DEK isolation (typically userId). */ + readonly userId: string; + /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ + readonly context: string; +} + +/** Key provider — abstraction over key storage backends. */ +export interface KeyProvider { + /** Wrap (encrypt) a DEK with the master key. Returns hex-encoded wrapped key + mek version string. */ + wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }>; + /** Unwrap (decrypt) a wrapped DEK. Returns raw DEK buffer. */ + unwrapKey(wrappedKeyHex: string, mekVersion: string): Promise; +} + +/** Supported key provider types. */ +export type KeyProviderType = 'akv' | 'env' | 'memory'; + +/** DEK store — abstraction over DEK persistence. */ +export interface DekStore { + /** Get a wrapped DEK by its ID. Returns null if not found. */ + get(dekId: string): Promise; + /** Store a wrapped DEK. */ + put(dek: WrappedDek): Promise; + /** List all DEK IDs (for rotation). */ + listIds(): Promise; + /** Delete a DEK. */ + delete(dekId: string): Promise; +} + +/** Configuration for createFieldEncryptor(). */ +export interface FieldEncryptorConfig { + /** + * Master toggle — set to false to disable encryption entirely. + * When disabled, encrypt() returns plaintext as-is and decrypt() passes through. + * Default: true. + */ + enabled?: boolean; + /** Key provider type. */ + keyProvider: KeyProviderType; + /** Azure Key Vault URL (required for 'akv' provider). */ + keyVaultUrl?: string; + /** MEK name in AKV (required for 'akv' provider). */ + mekName?: string; + /** Hex-encoded encryption key (required for 'env' provider). */ + encryptionKey?: string; + /** DEK cache TTL in milliseconds (default: 15 minutes). */ + dekCacheTtlMs?: number; + /** DEK cache max size (default: 1000). */ + dekCacheMaxSize?: number; + /** DEK store implementation (default: in-memory). */ + dekStore?: DekStore; +} diff --git a/vendor/bytelyst/field-encrypt/tsconfig.json b/vendor/bytelyst/field-encrypt/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/field-encrypt/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/gentle-notifications/package.json b/vendor/bytelyst/gentle-notifications/package.json new file mode 100644 index 0000000..71dd299 --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/gentle-notifications", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/gentle-notifications/src/client.test.ts b/vendor/bytelyst/gentle-notifications/src/client.test.ts new file mode 100644 index 0000000..22db4ce --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/src/client.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js'; + +describe('createGentleNotificationEngine', () => { + it('should return default config', () => { + const engine = createGentleNotificationEngine(); + const config = engine.getDefaultConfig(); + expect(config.maxPerHour).toBe(3); + expect(config.tone).toBe('encouraging'); + expect(config.adaptiveFrequency).toBe(true); + expect(config.dismissCount).toBe(0); + expect(config.suppressThreshold).toBe(5); + }); + + it('should return a message for known type', () => { + const engine = createGentleNotificationEngine(); + const msg = engine.getMessage('reminder'); + expect(msg.title.length).toBeGreaterThan(0); + expect(msg.body.length).toBeGreaterThan(0); + expect(['encouraging', 'neutral', 'minimal']).toContain(msg.tone); + }); + + it('should return fallback for unknown type', () => { + const engine = createGentleNotificationEngine(); + const msg = engine.getMessage('nonexistent_type'); + expect(msg.title).toBe('Hey'); + expect(msg.body.length).toBeGreaterThan(0); + }); + + it('should suppress when dismiss threshold reached', () => { + const engine = createGentleNotificationEngine(); + const config = { ...engine.getDefaultConfig(), dismissCount: 5 }; + expect(engine.shouldSuppress(config)).toBe(true); + }); + + it('should not suppress when below threshold', () => { + const engine = createGentleNotificationEngine(); + const config = { ...engine.getDefaultConfig(), dismissCount: 2 }; + expect(engine.shouldSuppress(config)).toBe(false); + }); + + it('should record dismissal and reduce frequency', () => { + const engine = createGentleNotificationEngine(); + let config = engine.getDefaultConfig(); + expect(config.dismissCount).toBe(0); + + config = engine.recordDismissal(config); + expect(config.dismissCount).toBe(1); + + // Record enough to trigger suppression + for (let i = 0; i < 4; i++) { + config = engine.recordDismissal(config); + } + expect(config.dismissCount).toBe(5); + expect(config.maxPerHour).toBeLessThan(3); + }); + + it('should reset dismissals', () => { + const engine = createGentleNotificationEngine(); + let config = engine.getDefaultConfig(); + config = engine.recordDismissal(config); + config = engine.recordDismissal(config); + expect(config.dismissCount).toBe(2); + + config = engine.resetDismissals(config); + expect(config.dismissCount).toBe(0); + }); + + it('should allow registering custom messages', () => { + const engine = createGentleNotificationEngine(); + engine.registerMessages('fasting', [ + { title: 'Fasting Reminder', body: 'Your body is doing great things!', tone: 'encouraging' }, + ]); + const msg = engine.getMessage('fasting'); + expect(msg.title).toBe('Fasting Reminder'); + }); + + it('should export FORBIDDEN_PHRASES', () => { + expect(FORBIDDEN_PHRASES).toContain("You haven't"); + expect(FORBIDDEN_PHRASES).toContain('You failed'); + expect(FORBIDDEN_PHRASES.length).toBeGreaterThanOrEqual(8); + }); + + it('should detect forbidden phrases', () => { + const engine = createGentleNotificationEngine(); + expect(engine.containsForbiddenPhrase("You haven't done this yet")).toBe(true); + expect(engine.containsForbiddenPhrase('Great job today!')).toBe(false); + expect(engine.containsForbiddenPhrase('you failed the test')).toBe(true); + }); + + it('should respect custom initial config', () => { + const engine = createGentleNotificationEngine({ maxPerHour: 10, tone: 'minimal' }); + const config = engine.getDefaultConfig(); + expect(config.maxPerHour).toBe(10); + expect(config.tone).toBe('minimal'); + }); +}); diff --git a/vendor/bytelyst/gentle-notifications/src/client.ts b/vendor/bytelyst/gentle-notifications/src/client.ts new file mode 100644 index 0000000..c4444da --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/src/client.ts @@ -0,0 +1,157 @@ +/** + * Neurodivergent-friendly notification messaging system. + * + * Encouraging tone, adaptive frequency, forbidden phrases. + * Pure client-side TS — no backend dependency. + */ + +import type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js'; + +export const FORBIDDEN_PHRASES: readonly string[] = [ + "You haven't", + 'You forgot', + "Don't forget", + 'You should have', + "Why didn't you", + 'You missed', + 'You failed', + 'You need to', +] as const; + +const DEFAULT_MESSAGES: Record = { + reminder: [ + { + title: 'Gentle Reminder', + body: 'Whenever you are ready, there is something waiting for you.', + tone: 'encouraging', + }, + { + title: 'Quick Note', + body: 'No rush — just a friendly nudge when the time feels right.', + tone: 'encouraging', + }, + { + title: 'Hey there', + body: 'Take your time. We will be here when you are ready.', + tone: 'neutral', + }, + ], + progress: [ + { + title: 'Nice Progress', + body: 'Look at what you have accomplished — every step matters!', + tone: 'encouraging', + }, + { + title: 'Moving Forward', + body: 'You are making progress at your own pace. That is perfect.', + tone: 'encouraging', + }, + ], + check_in: [ + { + title: 'Check In', + body: 'How are you feeling? Remember, there is no wrong answer.', + tone: 'encouraging', + }, + { title: 'Quick Check', body: 'Just checking in — hope you are doing well!', tone: 'neutral' }, + ], + streak: [ + { + title: 'Streak Update', + body: 'Your consistency is impressive — keep it going if it feels right!', + tone: 'encouraging', + }, + ], + idle: [ + { + title: 'Welcome Back', + body: 'Great to see you again — no judgment, just glad you are here!', + tone: 'encouraging', + }, + { + title: 'Hi Again', + body: 'Whenever you are ready to jump back in, we are here.', + tone: 'neutral', + }, + ], +}; + +export function createGentleNotificationEngine( + initialConfig?: Partial +): GentleNotificationEngine { + const messagePools: Record = { ...DEFAULT_MESSAGES }; + + function getDefaultConfig(): GentleNotificationConfig { + return { + maxPerHour: 3, + tone: 'encouraging', + adaptiveFrequency: true, + dismissCount: 0, + suppressThreshold: 5, + ...initialConfig, + }; + } + + function getMessage(type: string, config?: GentleNotificationConfig): GentleMessage { + const tone = config?.tone ?? 'encouraging'; + const pool = messagePools[type]; + + if (!pool || pool.length === 0) { + return { + title: 'Hey', + body: 'Hope you are having a good day!', + tone, + }; + } + + // Filter by tone if possible, fallback to any + const toneFiltered = pool.filter(m => m.tone === tone); + const candidates = toneFiltered.length > 0 ? toneFiltered : pool; + const index = Math.floor(Math.random() * candidates.length); + return candidates[index]; + } + + function shouldSuppress(config: GentleNotificationConfig): boolean { + if (config.adaptiveFrequency && config.dismissCount >= config.suppressThreshold) { + return true; + } + return false; + } + + function recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig { + const newConfig = { ...config, dismissCount: config.dismissCount + 1 }; + if (newConfig.adaptiveFrequency && newConfig.dismissCount >= newConfig.suppressThreshold) { + newConfig.maxPerHour = Math.max(1, Math.floor(newConfig.maxPerHour / 2)); + } + return newConfig; + } + + function resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig { + return { ...config, dismissCount: 0 }; + } + + function registerMessages(type: string, messages: GentleMessage[]): void { + messagePools[type] = [...(messagePools[type] ?? []), ...messages]; + } + + function getForbiddenPhrases(): readonly string[] { + return FORBIDDEN_PHRASES; + } + + function containsForbiddenPhrase(text: string): boolean { + const lower = text.toLowerCase(); + return FORBIDDEN_PHRASES.some(phrase => lower.includes(phrase.toLowerCase())); + } + + return { + getDefaultConfig, + getMessage, + shouldSuppress, + recordDismissal, + resetDismissals, + registerMessages, + getForbiddenPhrases, + containsForbiddenPhrase, + }; +} diff --git a/vendor/bytelyst/gentle-notifications/src/index.ts b/vendor/bytelyst/gentle-notifications/src/index.ts new file mode 100644 index 0000000..2be8615 --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/src/index.ts @@ -0,0 +1,44 @@ +export interface GentleConfig { + maxPerDay: number; + quietHoursStart: number; + quietHoursEnd: number; + minIntervalMinutes: number; + dismissCount?: number; +} + +const FORBIDDEN_PHRASES = [ + "you failed", + "you broke", + "you gave up", + "disappointed", + "shame", + "guilt", + "lazy", + "weak", + "cheat", +] as const; + +export function createGentleNotificationEngine() { + return { + containsForbiddenPhrase(text: string): boolean { + const lower = text.toLowerCase(); + return FORBIDDEN_PHRASES.some((phrase) => lower.includes(phrase)); + }, + + getDefaultConfig(): GentleConfig { + return { + maxPerDay: 8, + quietHoursStart: 22, + quietHoursEnd: 7, + minIntervalMinutes: 30, + }; + }, + + recordDismissal(config: GentleConfig): GentleConfig { + return { + ...config, + dismissCount: (config.dismissCount ?? 0) + 1, + }; + }, + }; +} diff --git a/vendor/bytelyst/gentle-notifications/src/types.ts b/vendor/bytelyst/gentle-notifications/src/types.ts new file mode 100644 index 0000000..7cdc031 --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/src/types.ts @@ -0,0 +1,29 @@ +/** + * Types for @bytelyst/gentle-notifications. + * Pure client-side TS — no backend dependency. + */ + +export interface GentleNotificationConfig { + maxPerHour: number; + tone: 'encouraging' | 'neutral' | 'minimal'; + adaptiveFrequency: boolean; + dismissCount: number; + suppressThreshold: number; +} + +export interface GentleMessage { + title: string; + body: string; + tone: 'encouraging' | 'neutral' | 'minimal'; +} + +export interface GentleNotificationEngine { + getDefaultConfig(): GentleNotificationConfig; + getMessage(type: string, config?: GentleNotificationConfig): GentleMessage; + shouldSuppress(config: GentleNotificationConfig): boolean; + recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig; + resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig; + registerMessages(type: string, messages: GentleMessage[]): void; + getForbiddenPhrases(): readonly string[]; + containsForbiddenPhrase(text: string): boolean; +} diff --git a/vendor/bytelyst/gentle-notifications/tsconfig.json b/vendor/bytelyst/gentle-notifications/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/gentle-notifications/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/.gitignore b/vendor/bytelyst/kotlin-platform-sdk/.gitignore new file mode 100644 index 0000000..4705270 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/.gitignore @@ -0,0 +1,11 @@ +# Gradle +.gradle/ +build/ +local.properties + +# IDE +.idea/ +*.iml + +# OS +.DS_Store diff --git a/vendor/bytelyst/kotlin-platform-sdk/README.md b/vendor/bytelyst/kotlin-platform-sdk/README.md new file mode 100644 index 0000000..78688e1 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/README.md @@ -0,0 +1,574 @@ +# ByteLyst Platform SDK — Android (Kotlin) + +Kotlin SDK for the ByteLyst platform. Provides broadcast messaging, surveys, authentication, telemetry, and more. + +## Installation + +### Gradle + +Add to your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("com.bytelyst:platform-sdk:1.0.0") +} +``` + +### Maven + +```xml + + com.bytelyst + platform-sdk + 1.0.0 + +``` + +## Quick Start + +```kotlin +import com.bytelyst.platform.* + +// Configure the SDK +val config = BLPlatformConfig( + productId = "lysnrai", + baseURL = "https://api.bytelyst.io/v1", + getAuthToken = { authRepository.getToken() } +) + +// Create clients +val broadcastClient = BLBroadcastClient(config) +val surveyClient = BLSurveyClient(config) +``` + +## Broadcast Client + +### Basic Usage + +```kotlin +import com.bytelyst.platform.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class BroadcastManager( + private val client: BLBroadcastClient +) { + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun startListening() { + client.startPolling(60000L) { messages -> + _messages.value = messages + _unreadCount.value = messages.count { it.status == MessageStatus.UNREAD } + } + } + + fun stopListening() { + client.stopPolling() + } + + suspend fun markRead(messageId: String) { + client.markRead(messageId) + } + + suspend fun dismiss(messageId: String) { + client.markDismissed(messageId) + } + + suspend fun handleTap(message: InAppMessage) { + client.trackClick(message.id) + + message.ctaUrl?.let { url -> + // Open URL with your navigation system + navigationService.openUrl(url) + } + + markRead(message.id) + } +} +``` + +### Jetpack Compose Integration + +```kotlin +import com.bytelyst.platform.ui.* + +@Composable +fun AppContent() { + val broadcastManager = remember { BroadcastManager(broadcastClient) } + val messages by broadcastManager.messages.collectAsState() + val unreadCount by broadcastManager.unreadCount.collectAsState() + + LaunchedEffect(Unit) { + broadcastManager.startListening() + } + + Scaffold( + topBar = { + // Banner for unread messages + InAppMessageBanner( + client = broadcastClient, + position = BannerPosition.TOP + ) + } + ) { padding -> + MainContent(modifier = Modifier.padding(padding)) + } +} +``` + +### Modal Messages + +```kotlin +@Composable +fun AppRoot() { + val broadcastClient = remember { BLBroadcastClient(config) } + + Box(modifier = Modifier.fillMaxSize()) { + NavigationHost() + + // Modal overlay + BroadcastModal(client = broadcastClient) + } +} +``` + +## Survey Client + +### Basic Usage + +```kotlin +import com.bytelyst.platform.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class SurveyManager( + private val client: BLSurveyClient +) { + private val _activeSurvey = MutableStateFlow(null) + val activeSurvey: StateFlow = _activeSurvey + + private val _currentQuestionIndex = MutableStateFlow(0) + val currentQuestionIndex: StateFlow = _currentQuestionIndex + + private val _answers = MutableStateFlow>(emptyMap()) + val answers: StateFlow> = _answers + + private val _isComplete = MutableStateFlow(false) + val isComplete: StateFlow = _isComplete + + suspend fun checkForSurveys() { + val result = client.getActiveSurvey() + result.onSuccess { survey -> + survey?.let { + _activeSurvey.value = it + client.startSurvey(it.id) + } + } + } + + suspend fun submitAnswer(question: Question, answer: SurveyAnswer) { + val survey = _activeSurvey.value ?: return + + val result = client.submitAnswer( + surveyId = survey.id, + questionId = question.id, + answer = answer + ) + + result.onSuccess { response -> + _currentQuestionIndex.value = response.currentQuestionIndex + _answers.value = _answers.value + (question.id to answer) + + if (response.isComplete) { + completeSurvey() + } + } + } + + suspend fun completeSurvey() { + val survey = _activeSurvey.value ?: return + + val result = client.completeSurvey(survey.id) + result.onSuccess { completion -> + if (completion.success) { + _isComplete.value = true + + if (completion.incentiveClaimed) { + showIncentiveToast( + amount = completion.incentiveAmount, + type = completion.incentiveType + ) + } + } + } + } + + suspend fun dismiss() { + val survey = _activeSurvey.value ?: return + client.dismissSurvey(survey.id) + + _activeSurvey.value = null + _currentQuestionIndex.value = 0 + _answers.value = emptyMap() + } +} +``` + +### Jetpack Compose Survey Modal + +```kotlin +@Composable +fun AppRoot() { + val surveyClient = remember { BLSurveyClient(config) } + + Box(modifier = Modifier.fillMaxSize()) { + NavigationHost() + + // Survey modal overlay + SurveyModal(client = surveyClient) + } +} +``` + +### Custom Survey UI + +```kotlin +@Composable +fun CustomSurveyView( + manager: SurveyManager = viewModel() +) { + val survey by manager.activeSurvey.collectAsState() + val currentIndex by manager.currentQuestionIndex.collectAsState() + val isComplete by manager.isComplete.collectAsState() + + if (survey != null && !isComplete) { + val question = survey!!.questions[currentIndex] + val isLast = currentIndex == survey!!.questions.size - 1 + + Column(modifier = Modifier.padding(16.dp)) { + // Progress + LinearProgressIndicator( + progress = (currentIndex + 1) / survey!!.questions.size.toFloat(), + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = "Question ${currentIndex + 1} of ${survey!!.questions.size}", + style = MaterialTheme.typography.labelSmall + ) + + // Question + Text( + text = question.text, + style = MaterialTheme.typography.titleLarge + ) + + question.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Answer input based on type + QuestionInput( + question = question, + onAnswer = { answer -> + coroutineScope.launch { + manager.submitAnswer(question, answer) + } + } + ) + + // Navigation + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (!question.required) { + TextButton(onClick = { /* Skip */ }) { + Text("Skip") + } + } + + Button( + onClick = { /* Submit */ }, + enabled = canSubmit(question) + ) { + Text(if (isLast) "Complete" else "Next") + } + } + } + } +} + +@Composable +fun QuestionInput( + question: Question, + onAnswer: (SurveyAnswer) -> Unit +) { + when (question.type) { + QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { + var selected by remember { mutableStateOf(null) } + + Column { + question.options?.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + selected = option.id + onAnswer(SurveyAnswer( + type = "single_choice", + value = JsonObject(mapOf("value" to JsonPrimitive(option.id))) + )) + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = option.emoji ?: "") + Text(text = option.text, modifier = Modifier.weight(1f)) + RadioButton( + selected = selected == option.id, + onClick = null + ) + } + } + } + } + + QuestionType.MULTIPLE_CHOICE -> { + var selected by remember { mutableStateOf>(emptySet()) } + + Column { + question.options?.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + selected = if (selected.contains(option.id)) { + selected - option.id + } else { + selected + option.id + } + onAnswer(SurveyAnswer( + type = "multiple_choice", + value = JsonObject(mapOf("values" to JsonArray(selected.map { JsonPrimitive(it) }))) + )) + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = option.emoji ?: "") + Text(text = option.text, modifier = Modifier.weight(1f)) + Checkbox( + checked = selected.contains(option.id), + onCheckedChange = null + ) + } + } + } + } + + QuestionType.NPS, QuestionType.RATING, QuestionType.SCALE -> { + var rating by remember { mutableIntStateOf(0) } + val minValue = question.minValue ?: if (question.type == QuestionType.NPS) 0 else 1 + val maxValue = question.maxValue ?: if (question.type == QuestionType.NPS) 10 else 5 + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + (minValue..maxValue).forEach { value -> + Button( + onClick = { + rating = value + onAnswer(SurveyAnswer( + type = "rating", + value = JsonObject(mapOf("value" to JsonPrimitive(value))) + )) + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (rating == value) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text("$value") + } + } + } + } + + QuestionType.TEXT_SHORT -> { + var text by remember { mutableStateOf("") } + OutlinedTextField( + value = text, + onValueChange = { + text = it + onAnswer(SurveyAnswer( + type = "text", + value = JsonObject(mapOf("value" to JsonPrimitive(it))) + )) + }, + modifier = Modifier.fillMaxWidth() + ) + } + + QuestionType.TEXT_LONG -> { + var text by remember { mutableStateOf("") } + OutlinedTextField( + value = text, + onValueChange = { + text = it + onAnswer(SurveyAnswer( + type = "text", + value = JsonObject(mapOf("value" to JsonPrimitive(it))) + )) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + maxLines = 6 + ) + } + + else -> { /* Handle other types */ } + } +} +``` + +## Push Notifications + +### Firebase Cloud Messaging + +```kotlin +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.bytelyst.platform.* + +class PushNotificationService : FirebaseMessagingService() { + private lateinit var broadcastClient: BLBroadcastClient + + override fun onCreate() { + super.onCreate() + val config = BLPlatformConfig( + productId = "lysnrai", + baseURL = "https://api.bytelyst.io/v1", + getAuthToken = { authRepository.getToken() } + ) + broadcastClient = BLBroadcastClient(config) + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + + // Register device token + coroutineScope.launch { + broadcastClient.registerDeviceToken(token, Platform.ANDROID) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + // Handle broadcast notification + message.data["broadcastId"]?.let { broadcastId -> + showNotification(message) + } + } +} +``` + +### Register Device Token + +```kotlin +FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + val token = task.result + coroutineScope.launch { + broadcastClient.registerDeviceToken(token, Platform.ANDROID) + } + } +} +``` + +## Error Handling + +```kotlin +val result = client.getActiveSurvey() + +result.onSuccess { survey -> + survey?.let { showSurvey(it) } +}.onError { error -> + when (error) { + is BLApiError.Unauthorized -> { + // Re-authenticate user + authRepository.refreshToken() + } + is BLApiError.NetworkError -> { + // Retry with exponential backoff + Log.w("Survey", "Network error, will retry") + } + is BLApiError.ServerError -> { + Log.e("Survey", "Server error: ${error.code}") + } + else -> { + Log.e("Survey", "Unknown error: ${error.message}") + } + } +} +``` + +## Configuration + +### Environment-Based Config + +```kotlin +sealed class Environment( + val baseURL: String +) { + object Development : Environment("http://localhost:4003") + object Staging : Environment("https://api-staging.bytelyst.io/v1") + object Production : Environment("https://api.bytelyst.io/v1") +} + +val config = BLPlatformConfig( + productId = "lysnrai", + baseURL = Environment.Production.baseURL, + getAuthToken = { authRepository.getToken() }, + enableLogging = BuildConfig.DEBUG +) +``` + +## Offline Support + +The SDK automatically caches survey responses offline: + +```kotlin +val client = BLSurveyClient( + config = config, + enableOfflineCache = true // Enabled by default +) + +// Responses are queued when offline +// Flush manually or on network restore +connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + coroutineScope.launch { + client.flushOfflineQueue() + } + } +}) +``` + +## Requirements + +- Android 8.0+ (API 26+) +- Kotlin 1.9+ +- Jetpack Compose (optional, for UI components) + +## License + +MIT © ByteLyst diff --git a/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts new file mode 100644 index 0000000..e0e47a5 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + id("com.android.library") version "8.7.3" + id("org.jetbrains.kotlin.android") version "2.1.0" + id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" +} + +android { + namespace = "com.bytelyst.platform" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +group = "com.bytelyst.platform" +version = "0.1.0" + +dependencies { + // HTTP + api("com.squareup.okhttp3:okhttp:4.12.0") + + // Serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + // Compose UI (for SurveyUI, InAppMessageUI, BroadcastUI) + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.foundation:foundation") + + // Image loading (for BroadcastUI AsyncImage) + implementation("io.coil-kt:coil-compose:2.7.0") + + // Android + implementation("androidx.security:security-crypto:1.0.0") + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + + // Credential Manager (Passkeys) + implementation("androidx.credentials:credentials:1.5.0-beta01") + implementation("androidx.credentials:credentials-play-services-auth:1.5.0-beta01") + + // Testing + testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("org.robolectric:robolectric:4.14.1") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro b/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro new file mode 100644 index 0000000..a774057 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro @@ -0,0 +1,3 @@ +# ByteLyst Platform SDK — consumer ProGuard rules +# Keep all SDK public API classes +-keep class com.bytelyst.platform.** { *; } diff --git a/vendor/bytelyst/kotlin-platform-sdk/gradle.properties b/vendor/bytelyst/kotlin-platform-sdk/gradle.properties new file mode 100644 index 0000000..5bac8ac --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts new file mode 100644 index 0000000..b4d1db6 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "kotlin-platform-sdk" diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml b/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1f26d1e --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt new file mode 100644 index 0000000..57fb13b --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt @@ -0,0 +1,115 @@ +package com.bytelyst.platform + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Local rotating JSON audit log. + * + * Writes audit entries to a JSON lines file in the app's files directory. + * Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files. + * + * Mirrors the Swift BLAuditLogger API. + */ +class BLAuditLogger( + context: Context, + private val config: BLPlatformConfig, + private val maxFileSizeBytes: Long = 1_000_000L, + private val maxFiles: Int = 5, +) { + @Serializable + data class AuditEntry( + val timestamp: String, + val productId: String, + val action: String, + val module: String, + val detail: String? = null, + val userId: String? = null, + ) + + private val json = Json { encodeDefaults = true } + private val logDir = File(context.filesDir, "audit_logs") + private val currentFile: File + get() = File(logDir, "${config.productId}_audit.jsonl") + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ + @Synchronized + private fun isoNow(): String = isoFormat.format(Date()) + + init { + logDir.mkdirs() + } + + /** + * Log an audit event. + */ + fun log(action: String, module: String, detail: String? = null, userId: String? = null) { + val entry = AuditEntry( + timestamp = isoNow(), + productId = config.productId, + action = action, + module = module, + detail = detail, + userId = userId, + ) + + try { + rotateIfNeeded() + currentFile.appendText(json.encodeToString(entry) + "\n") + } catch (_: Exception) { + // Audit logging should never crash the app + } + } + + /** + * Read all entries from the current log file. + */ + fun readEntries(): List { + return try { + if (!currentFile.exists()) return emptyList() + currentFile.readLines() + .filter { it.isNotBlank() } + .mapNotNull { + try { json.decodeFromString(it) } catch (_: Exception) { null } + } + } catch (_: Exception) { + emptyList() + } + } + + /** + * Clear all audit log files. + */ + fun clear() { + try { + logDir.listFiles()?.forEach { it.delete() } + } catch (_: Exception) { + // Ignore + } + } + + private fun rotateIfNeeded() { + if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return + + // Rotate: current → .1, .1 → .2, etc. + for (i in maxFiles downTo 1) { + val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl") + val to = File(logDir, "${config.productId}_audit.$i.jsonl") + if (from.exists()) { + to.delete() + from.renameTo(to) + } + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt new file mode 100644 index 0000000..25222b1 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt @@ -0,0 +1,641 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer + +/** + * Auth client for platform-service. + * + * Manages login, register, token refresh, password operations. + * SmartAuth v2: social login, MFA (TOTP), device trust, step-up auth. + * Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences). + * Auth state is exposed as a [StateFlow] for reactive UI binding. + * + * Mirrors the Swift BLAuthClient API. + */ +class BLAuthClient( + private val config: BLPlatformConfig, + private val secureStore: BLSecureStore, +) { + // ── Data classes ────────────────────────────────────────── + + @Serializable + data class AuthUser( + val id: String = "", + val email: String = "", + @SerialName("displayName") + val name: String = "", + val plan: String = "free", + val role: String = "user", + ) + + @Serializable + data class TokenResponse( + val accessToken: String, + val refreshToken: String, + val user: AuthUser, + ) + + @Serializable + data class RefreshResponse( + val accessToken: String, + val refreshToken: String, + ) + + @Serializable + data class MessageResponse(val message: String) + + // ── SmartAuth v2 types ──────────────────────────────────── + + @Serializable + data class MfaChallenge( + val mfaRequired: Boolean = false, + val mfaChallenge: String = "", + val methods: List = emptyList(), + ) + + @Serializable + data class TotpSetup( + val otpauthUri: String, + val qrCode: String, + val recoveryCodes: List, + ) + + @Serializable + data class MfaStatus( + val mfaEnabled: Boolean, + val methods: List, + val recoveryCodesRemaining: Int, + ) + + @Serializable + data class AuthProvider( + val provider: String, + val email: String, + val linkedAt: String, + val lastUsedAt: String? = null, + ) + + @Serializable + data class Device( + val fingerprint: String, + val trustLevel: String, + val deviceInfo: DeviceInfo? = null, + val lastIp: String? = null, + val lastLocation: String? = null, + val trustExpiresAt: String? = null, + val createdAt: String, + val lastSeenAt: String, + val isTrusted: Boolean, + ) { + /** Convenience: display name from device info. */ + val name: String get() = deviceInfo?.model ?: deviceInfo?.platform ?: fingerprint.take(8) + /** Convenience: platform string. */ + val platform: String get() = deviceInfo?.platform ?: "unknown" + } + + @Serializable + data class DeviceInfo( + val userAgent: String? = null, + val platform: String? = null, + val model: String? = null, + val os: String? = null, + ) + + @Serializable + data class LoginEvent( + val id: String, + val eventType: String, + val method: String, + val ip: String, + val geo: Geo? = null, + val riskScore: Int, + val createdAt: String, + ) + + @Serializable + data class Geo( + val country: String, + val city: String, + ) + + @Serializable + data class RecoveryCodesResponse( + val recoveryCodes: List, + ) + + @Serializable + data class Passkey( + val id: String, + val friendlyName: String, + val deviceType: String, + val lastUsedAt: String? = null, + val createdAt: String = "", + ) + + @Serializable + data class StepUpResponse( + val stepUpToken: String, + ) + + // ── Phase 5C–5E types ───────────────────────────────────── + + @Serializable + data class TotpSecret( + val secret: String, + val issuer: String, + val accountName: String, + val digits: Int = 6, + val period: Int = 30, + val algorithm: String = "SHA1", + ) + + @Serializable + data class PushApproval( + val id: String, + val requestProductId: String, + val requestPlatform: String, + val requestIp: String, + val requestGeo: Geo? = null, + val createdAt: String, + val expiresAt: String, + ) + + @Serializable + data class PushApprovalResponse( + val id: String, + val status: String, + val respondedAt: String? = null, + ) + + @Serializable + data class QrChallenge( + val id: String, + val challengeToken: String, + val expiresAt: String, + ) + + @Serializable + data class QrStatus( + val status: String, + val accessToken: String? = null, + val refreshToken: String? = null, + val user: AuthUser? = null, + ) + + /** Exception when MFA is required after login. */ + class MfaRequiredException(val challenge: MfaChallenge) : Exception("MFA required") + + sealed class AuthState { + data object Loading : AuthState() + data object LoggedOut : AuthState() + data class LoggedIn(val user: AuthUser) : AuthState() + data class MfaRequired(val challenge: MfaChallenge) : AuthState() + data class Error(val message: String) : AuthState() + } + + // ── State ──────────────────────────────────────────────── + + private val _state = MutableStateFlow(AuthState.Loading) + val state: StateFlow = _state.asStateFlow() + + val isLoggedIn: Boolean + get() = _state.value is AuthState.LoggedIn + + val currentUser: AuthUser? + get() = (_state.value as? AuthState.LoggedIn)?.user + + // ── Storage keys (bare — applicationId provides namespace) ── + + companion object { + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + private const val KEY_USER_EMAIL = "user_email" + private const val KEY_USER_NAME = "user_name" + private const val KEY_USER_PLAN = "user_plan" + private const val KEY_USER_ID = "user_id" + } + + // ── Platform client ────────────────────────────────────── + + internal val client = BLPlatformClient(config) { getAccessToken() } + + // ── Token management ───────────────────────────────────── + + fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN) + + fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN) + + private fun setTokens(accessToken: String, refreshToken: String) { + secureStore.save(KEY_ACCESS_TOKEN, accessToken) + secureStore.save(KEY_REFRESH_TOKEN, refreshToken) + } + + private fun saveUser(user: AuthUser) { + secureStore.save(KEY_USER_ID, user.id) + secureStore.save(KEY_USER_EMAIL, user.email) + secureStore.save(KEY_USER_NAME, user.name) + secureStore.save(KEY_USER_PLAN, user.plan) + } + + private fun clearAll() { + secureStore.delete(KEY_ACCESS_TOKEN) + secureStore.delete(KEY_REFRESH_TOKEN) + secureStore.delete(KEY_USER_EMAIL) + secureStore.delete(KEY_USER_NAME) + secureStore.delete(KEY_USER_PLAN) + secureStore.delete(KEY_USER_ID) + } + + // ── Session check ──────────────────────────────────────── + + /** + * Check for an existing session from stored tokens. + * Call once at app startup to restore auth state. + */ + fun checkExistingSession() { + val token = secureStore.read(KEY_ACCESS_TOKEN) + val email = secureStore.read(KEY_USER_EMAIL) + if (!token.isNullOrBlank() && !email.isNullOrBlank()) { + val user = AuthUser( + id = secureStore.read(KEY_USER_ID) ?: "", + email = email, + name = secureStore.read(KEY_USER_NAME) ?: "", + plan = secureStore.read(KEY_USER_PLAN) ?: "free", + ) + _state.value = AuthState.LoggedIn(user) + } else { + _state.value = AuthState.LoggedOut + } + } + + // ── Auth operations ────────────────────────────────────── + + suspend fun login(email: String, password: String) { + _state.value = AuthState.Loading + try { + val body = encodeMap(mapOf("email" to email, "password" to password, "productId" to config.productId)) + val response = client.request("POST", "/api/auth/login", body, skipAuth = true) + // Check for MFA challenge + try { + val challenge = client.json.decodeFromString(response) + if (challenge.mfaRequired) { + _state.value = AuthState.MfaRequired(challenge) + return + } + } catch (_: Exception) { /* Not an MFA response, continue */ } + val result = client.json.decodeFromString(response) + handleAuthResult(result) + } catch (e: Exception) { + _state.value = AuthState.Error(e.message ?: "Login failed") + } + } + + suspend fun register(name: String, email: String, password: String) { + _state.value = AuthState.Loading + try { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf( + "email" to email, + "displayName" to name, + "password" to password, + "productId" to config.productId, + ), + ) + val response = client.request("POST", "/api/auth/register", body, skipAuth = true) + val result = client.json.decodeFromString(response) + handleAuthResult(result) + } catch (e: Exception) { + _state.value = AuthState.Error(e.message ?: "Registration failed") + } + } + + fun logout() { + clearAll() + _state.value = AuthState.LoggedOut + } + + // ── Token refresh (singleton guard) ────────────────────── + + private val refreshMutex = Mutex() + + suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { + val rt = getRefreshToken() ?: return false + return try { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("refreshToken" to rt), + ) + val response = client.request("POST", "/api/auth/refresh", body, skipAuth = true) + val result = client.json.decodeFromString(response) + setTokens(result.accessToken, result.refreshToken) + true + } catch (e: BLApiException) { + if (e.statusCode == 401) { + withContext(Dispatchers.Main) { logout() } + } + false + } catch (_: Exception) { + false + } + } + + // ── Password management ────────────────────────────────── + + suspend fun forgotPassword(email: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("email" to email, "productId" to config.productId), + ) + val response = client.request("POST", "/api/auth/forgot-password", body, skipAuth = true) + return client.json.decodeFromString(response).message + } + + suspend fun resetPassword(token: String, newPassword: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("token" to token, "newPassword" to newPassword), + ) + val response = client.request("POST", "/api/auth/reset-password", body, skipAuth = true) + return client.json.decodeFromString(response).message + } + + suspend fun changePassword(currentPassword: String, newPassword: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("currentPassword" to currentPassword, "newPassword" to newPassword), + ) + val response = client.request("POST", "/api/auth/change-password", body) + return client.json.decodeFromString(response).message + } + + // ── Email verification ─────────────────────────────────── + + suspend fun verifyEmail(token: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("token" to token), + ) + val response = client.request("POST", "/api/auth/verify-email", body) + return client.json.decodeFromString(response).message + } + + suspend fun resendVerification(email: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("email" to email, "productId" to config.productId), + ) + val response = client.request("POST", "/api/auth/resend-verification", body) + return client.json.decodeFromString(response).message + } + + // ── Account management ─────────────────────────────────── + + suspend fun deleteAccount(password: String): String { + val body = client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("password" to password), + ) + val response = client.request("DELETE", "/api/auth/account", body) + clearAll() + _state.value = AuthState.LoggedOut + return client.json.decodeFromString(response).message + } + + suspend fun getMe(): AuthUser { + val response = client.request("GET", "/api/auth/me") + return client.json.decodeFromString(response) + } + + // ── Social Login (SmartAuth v2) ───────────────────────── + + /** Login with Google id_token. */ + suspend fun loginWithGoogle(idToken: String): AuthUser = socialLogin("google", idToken) + + /** Login with Microsoft id_token. */ + suspend fun loginWithMicrosoft(idToken: String): AuthUser = socialLogin("microsoft", idToken) + + /** Login with Apple id_token. */ + suspend fun loginWithApple(idToken: String): AuthUser = socialLogin("apple", idToken) + + private suspend fun socialLogin(provider: String, idToken: String): AuthUser { + _state.value = AuthState.Loading + val body = encodeMap(mapOf("idToken" to idToken, "productId" to config.productId)) + val response = client.request("POST", "/api/auth/oauth/$provider", body, skipAuth = true) + // Check for MFA challenge + try { + val challenge = client.json.decodeFromString(response) + if (challenge.mfaRequired) { + _state.value = AuthState.MfaRequired(challenge) + throw MfaRequiredException(challenge) + } + } catch (e: MfaRequiredException) { + throw e + } catch (_: Exception) { /* Not an MFA response */ } + val result = client.json.decodeFromString(response) + handleAuthResult(result) + return result.user + } + + // ── MFA (SmartAuth v2) ─────────────────────────────────── + + /** Verify MFA challenge (TOTP code or recovery code). */ + suspend fun verifyMfa(challengeToken: String, code: String, method: String = "totp"): AuthUser { + val body = encodeMap(mapOf( + "challengeToken" to challengeToken, + "code" to code, + "method" to method, + )) + val response = client.request("POST", "/api/auth/mfa/verify", body, skipAuth = true) + val result = client.json.decodeFromString(response) + handleAuthResult(result) + return result.user + } + + /** Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. */ + suspend fun setupTotp(): TotpSetup { + val response = client.request("POST", "/api/auth/mfa/totp/setup") + return client.json.decodeFromString(response) + } + + /** Verify TOTP setup with a code from the authenticator app. */ + suspend fun verifyTotpSetup(code: String) { + val body = encodeMap(mapOf("code" to code)) + client.request("POST", "/api/auth/mfa/totp/verify-setup", body) + } + + /** Disable MFA (requires step-up token). */ + suspend fun disableMfa() { + client.request("DELETE", "/api/auth/mfa/totp") + } + + /** Get current MFA status. */ + suspend fun getMfaStatus(): MfaStatus { + val response = client.request("GET", "/api/auth/mfa/status") + return client.json.decodeFromString(response) + } + + /** Regenerate recovery codes (requires step-up). */ + suspend fun regenerateRecoveryCodes(): List { + val response = client.request("POST", "/api/auth/mfa/recovery/regenerate") + return client.json.decodeFromString(response).recoveryCodes + } + + // ── Providers (SmartAuth v2) ───────────────────────────── + + /** List linked OAuth providers. */ + suspend fun getProviders(): List { + val response = client.request("GET", "/api/auth/providers") + return client.json.decodeFromString>(response) + } + + /** Link an OAuth provider to the current account. */ + suspend fun linkProvider(provider: String, idToken: String) { + val body = encodeMap(mapOf("provider" to provider, "idToken" to idToken)) + client.request("POST", "/api/auth/providers/link", body) + } + + /** Unlink an OAuth provider. */ + suspend fun unlinkProvider(provider: String) { + client.request("DELETE", "/api/auth/providers/$provider") + } + + // ── Devices (SmartAuth v2) ─────────────────────────────── + + @Serializable + private data class DevicesResponse(val devices: List) + + /** List devices for current user. */ + suspend fun listDevices(): List { + val response = client.request("GET", "/api/auth/devices") + return client.json.decodeFromString(response).devices + } + + /** Trust the current device (promotes to trusted, skips MFA for 90 days). */ + suspend fun trustDevice() { + client.request("POST", "/api/auth/devices/trust") + } + + /** Revoke trust on a specific device by fingerprint. */ + suspend fun revokeDevice(fingerprint: String) { + client.request("DELETE", "/api/auth/devices/$fingerprint") + } + + /** Revoke all device trust. */ + suspend fun revokeAllDevices() { + client.request("POST", "/api/auth/devices/revoke-all") + } + + // ── Step-Up Auth (SmartAuth v2) ────────────────────────── + + /** Perform step-up authentication. Returns a short-lived step-up token. */ + suspend fun stepUp(method: String, credential: String): String { + val body = encodeMap(mapOf("method" to method, "credential" to credential)) + val response = client.request("POST", "/api/auth/step-up", body) + return client.json.decodeFromString(response).stepUpToken + } + + // ── Login History (SmartAuth v2) ───────────────────────── + + @Serializable + private data class EventsResponse(val events: List) + + /** Get login events for the current user. */ + suspend fun getLoginHistory(limit: Int = 20): List { + val response = client.request("GET", "/api/auth/login-events?limit=$limit") + return client.json.decodeFromString(response).events + } + + // ── TOTP Secret Retrieval (Phase 5C) ───────────────────── + + /** Get the decrypted TOTP secret for local code generation (auth app). */ + suspend fun getTotpSecret(): TotpSecret { + val response = client.request("GET", "/api/auth/mfa/totp/secret") + return client.json.decodeFromString(response) + } + + // ── Push Approvals (Phase 5D) ──────────────────────────── + + /** List pending push MFA approvals for the current user. */ + suspend fun getPendingApprovals(): List { + val response = client.request("GET", "/api/auth/mfa/push/pending") + return client.json.decodeFromString>(response) + } + + /** Respond to a push MFA approval (approve or deny). */ + suspend fun respondToApproval(approvalId: String, action: String): PushApprovalResponse { + val body = encodeMap(mapOf("action" to action)) + val response = client.request("POST", "/api/auth/mfa/push/$approvalId/respond", body) + return client.json.decodeFromString(response) + } + + // ── QR Auth (Phase 5E) ─────────────────────────────────── + + /** Confirm a QR login challenge from the auth app. */ + suspend fun confirmQrLogin(challengeToken: String): MessageResponse { + val body = encodeMap(mapOf("challengeToken" to challengeToken)) + val response = client.request("POST", "/api/auth/qr/confirm", body) + return client.json.decodeFromString(response) + } + + // ── Session restore ───────────────────────────────────── + + /** + * Restore session from stored tokens. Call on app launch. + * Attempts to fetch current user; falls back to token refresh. + * Mirrors Swift BLAuthClient.restoreSession(). + */ + suspend fun restoreSession() { + val token = getAccessToken() + if (token.isNullOrBlank()) { + _state.value = AuthState.LoggedOut + return + } + _state.value = AuthState.Loading + try { + val user = getMe() + _state.value = AuthState.LoggedIn(user) + } catch (_: Exception) { + // Access token may be expired — try refresh + val refreshed = refreshAccessToken() + if (refreshed) { + try { + val user = getMe() + _state.value = AuthState.LoggedIn(user) + } catch (_: Exception) { + _state.value = AuthState.LoggedOut + } + } else { + _state.value = AuthState.LoggedOut + } + } + } + + // ── Private ────────────────────────────────────────────── + + private fun handleAuthResult(result: TokenResponse) { + setTokens(result.accessToken, result.refreshToken) + saveUser(result.user) + _state.value = AuthState.LoggedIn(result.user) + } + + /** Called by [BLPasskeyManager] after successful passkey authentication. */ + internal fun handleLoginResult(result: TokenResponse) = handleAuthResult(result) + + /** Encode a Map to JSON string. */ + private fun encodeMap(map: Map): String = + client.json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + map, + ) +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt new file mode 100644 index 0000000..0da5b11 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt @@ -0,0 +1,83 @@ +package com.bytelyst.platform + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Biometric authentication wrapper (Face / Fingerprint). + * + * Uses AndroidX BiometricPrompt for hardware-backed authentication. + * Mirrors the Swift BLBiometricAuth API. + */ +object BLBiometricAuth { + + enum class BiometricResult { + SUCCESS, + CANCELLED, + NOT_AVAILABLE, + ERROR, + } + + /** + * Check if biometric authentication is available on this device. + */ + fun isAvailable(activity: FragmentActivity): Boolean { + val manager = BiometricManager.from(activity) + return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + /** + * Show a biometric prompt and return the result. + * + * Must be called from a FragmentActivity (Compose activities extend this). + */ + suspend fun authenticate( + activity: FragmentActivity, + title: String = "Authenticate", + subtitle: String? = null, + negativeButtonText: String = "Cancel", + ): BiometricResult { + if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE + + return suspendCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + continuation.resume(BiometricResult.SUCCESS) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val result = if ( + errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricPrompt.ERROR_CANCELED + ) { + BiometricResult.CANCELLED + } else { + BiometricResult.ERROR + } + continuation.resume(result) + } + + override fun onAuthenticationFailed() { + // Individual attempt failed, prompt stays open — don't resume yet + } + } + + val prompt = BiometricPrompt(activity, executor, callback) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .apply { subtitle?.let { setSubtitle(it) } } + .setNegativeButtonText(negativeButtonText) + .build() + + prompt.authenticate(info) + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt new file mode 100644 index 0000000..1b6ec9d --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt @@ -0,0 +1,93 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Azure Blob Storage client via platform-service SAS tokens. + * + * Flow: Get SAS token from POST /api/blob/sas → Upload directly to Azure Blob. + * Mirrors the Swift BLBlobClient API. + */ +class BLBlobClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + @Serializable + data class SasResponse( + val sasUrl: String, + val blobUrl: String, + ) + + private val json = Json { ignoreUnknownKeys = true } + + private val platformClient = BLPlatformClient(config, tokenProvider) + + private val uploadClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Upload data to Azure Blob Storage. + * + * @param data The raw bytes to upload. + * @param container Azure Blob container name (e.g., "audio", "attachments"). + * @param fileName The blob name / file path. + * @param contentType MIME type (e.g., "audio/wav", "image/png"). + * @return The public blob URL on success, or null on failure. + */ + suspend fun upload( + data: ByteArray, + container: String, + fileName: String, + contentType: String, + ): String? = withContext(Dispatchers.IO) { + try { + // Step 1: Get SAS token from platform-service + val sasBody = json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf( + "container" to container, + "blobName" to fileName, + "permissions" to "w", + "contentType" to contentType, + ), + ) + val sasResponse = platformClient.request("POST", "/api/blob/sas", sasBody) + val sas = json.decodeFromString(sasResponse) + + // Step 2: Upload directly to Azure Blob via SAS URL + val request = Request.Builder() + .url(sas.sasUrl) + .put(data.toRequestBody(contentType.toMediaType())) + .header("x-ms-blob-type", "BlockBlob") + .header("x-ms-blob-content-type", contentType) + .build() + + uploadClient.newCall(request).execute().use { response -> + if (response.isSuccessful) sas.blobUrl else null + } + } catch (_: Exception) { + null + } + } + + /** + * Upload audio data (convenience method). + */ + suspend fun uploadAudio(data: ByteArray, fileName: String): String? { + return upload(data, "audio", fileName, "audio/wav") + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt new file mode 100644 index 0000000..52d12c2 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt @@ -0,0 +1,223 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +// Top-level enums used by BroadcastUI +enum class MessagePriority { LOW, NORMAL, HIGH, URGENT } +enum class MessageStyle { BANNER, MODAL, TOAST, FULLSCREEN } +enum class MessageStatus { UNREAD, READ, DISMISSED } + +// Top-level type alias for convenience +typealias InAppMessage = BLBroadcastClient.InAppMessage + +/** + * Broadcast Client — In-app message client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLBroadcastClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: kotlinx.coroutines.Job? = null + + enum class Priority { + LOW, NORMAL, HIGH, URGENT + } + + enum class Style { + BANNER, MODAL, TOAST, FULLSCREEN + } + + enum class Status { + UNREAD, READ, DISMISSED + } + + @Serializable + data class InAppMessage( + val id: String, + val userId: String, + val productId: String, + val broadcastId: String, + val title: String, + val body: String, + val bodyMarkdown: String? = null, + val ctaText: String? = null, + val ctaUrl: String? = null, + val priority: String, + val style: String, + val dismissible: Boolean, + val expiresAt: String? = null, + val imageUrl: String? = null, + val status: String, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class MessagesResponse( + val messages: List + ) + + @Serializable + private data class ClickResponse( + val success: Boolean, + val redirectUrl: String? = null, + ) + + /** + * List active in-app messages for the current user. + */ + suspend fun getMessages() = listMessages() + + suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/broadcasts") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(MessagesResponse.serializer(), body) + Result.success(result.messages) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as read. + */ + suspend fun markRead(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/read", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Mark a message as dismissed. + */ + suspend fun markDismissed(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Track a CTA click and get the redirect URL. + */ + suspend fun trackClick(messageId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/broadcasts/$messageId/click", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.success(null) + val result = json.decodeFromString(ClickResponse.serializer(), body) + Result.success(result.redirectUrl) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start polling for new messages. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (List) -> Unit, + ) { + stopPolling() + pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + listMessages() + .onSuccess { messages -> onUpdate(messages) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for messages. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaType())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt new file mode 100644 index 0000000..515f109 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt @@ -0,0 +1,106 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +/** + * Crash reporter — captures uncaught exceptions and reports to telemetry. + * + * Installs a [Thread.UncaughtExceptionHandler] that: + * 1. Saves crash info to SharedPreferences + * 2. On next app launch, sends the crash report to platform-service telemetry + * 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs) + * + * Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android). + */ +class BLCrashReporter( + context: Context, + private val config: BLPlatformConfig, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE) + private val client = BLPlatformClient(config) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val json = Json { encodeDefaults = true } + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ + @Synchronized + private fun isoNow(): String = isoFormat.format(Date()) + + companion object { + private const val KEY_PENDING_CRASH = "pending_crash" + } + + /** + * Install the crash handler and send any pending crash reports. + * Call once from Application.onCreate(). + */ + fun install() { + sendPendingCrashReport() + installHandler() + } + + private fun installHandler() { + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + + val crashData = buildJsonObject { + put("id", UUID.randomUUID().toString()) + put("productId", config.productId) + put("platform", config.platform) + put("timestamp", isoNow()) + put("thread", thread.name) + put("exception", throwable.javaClass.name) + put("message", throwable.message ?: "") + put("stackTrace", sw.toString().take(4096)) + } + + // Persist synchronously (we may be about to die) + prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit() + + // Delegate to previous handler + previousHandler?.uncaughtException(thread, throwable) + } + } + + private fun sendPendingCrashReport() { + val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return + prefs.edit().remove(KEY_PENDING_CRASH).apply() + + scope.launch { + try { + val crashEvent = json.parseToJsonElement(pending) + val payload = buildJsonObject { + put("productId", config.productId) + put("events", JsonArray(listOf(crashEvent))) + } + client.fireAndForget("POST", "/api/telemetry/events", json.encodeToString(payload)) + } catch (_: Exception) { + // Best-effort — don't re-queue + } + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt new file mode 100644 index 0000000..34be026 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt @@ -0,0 +1,92 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URLEncoder + +/** + * Feature flag client for platform-service. + * + * Polls GET /api/flags/poll on a configurable interval and caches results + * in memory. Consumers call [isEnabled] with a flag key. + * + * Mirrors the Swift BLFeatureFlagClient API. + */ +class BLFeatureFlagClient( + private val config: BLPlatformConfig, + private val pollIntervalMs: Long = 5 * 60 * 1000L, +) { + @Serializable + private data class FlagResponse(val flags: Map = emptyMap()) + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var pollJob: Job? = null + + @Volatile + private var flags: Map = emptyMap() + + private var userId: String? = null + + // ── Public API ─────────────────────────────────────────── + + /** + * Initialize and start polling. Call once at app startup. + */ + fun init(userId: String? = null) { + this.userId = userId + scope.launch { fetchFlags() } + startPolling() + } + + fun isEnabled(key: String): Boolean = flags[key] == true + + fun getAllFlags(): Map = flags.toMap() + + /** + * Force a refresh of feature flags. + */ + suspend fun refresh() { + fetchFlags() + } + + fun stop() { + pollJob?.cancel() + pollJob = null + } + + // ── Private ────────────────────────────────────────────── + + private fun startPolling() { + if (pollJob != null) return + pollJob = scope.launch { + while (isActive) { + delay(pollIntervalMs) + fetchFlags() + } + } + } + + private suspend fun fetchFlags() { + try { + val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } + val qs = buildString { + append("?platform=${enc(config.platform)}") + userId?.let { append("&userId=${enc(it)}") } + } + val response = client.request("GET", "/api/flags/poll$qs", skipAuth = true) + val result = json.decodeFromString(response) + flags = result.flags + } catch (_: Exception) { + // Keep existing flags on failure + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt new file mode 100644 index 0000000..6fefb58 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt @@ -0,0 +1,266 @@ +package com.bytelyst.platform + +import android.content.Context +import android.graphics.Bitmap +import android.view.View +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** + * Feedback client for submitting user feedback with optional screenshots. + * + * TODO-3: Full implementation for Android + * + * Flow: + * 1. Capture screenshot (optional) + * 2. Get SAS URL for upload + * 3. Upload screenshot to blob storage + * 4. Submit feedback with metadata + */ +class BLFeedbackClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + enum class FeedbackType { + BUG, FEATURE, PRAISE, OTHER + } + + enum class ScreenshotFormat { + PNG, JPEG, WEBP + } + + data class DeviceContext( + val osVersion: String, + val appVersion: String, + val deviceModel: String, + val screenResolution: String, + val locale: String, + ) { + companion object { + fun fromContext(context: Context): DeviceContext { + val displayMetrics = context.resources.displayMetrics + return DeviceContext( + osVersion = android.os.Build.VERSION.RELEASE, + appVersion = context.packageManager.getPackageInfo( + context.packageName, 0 + ).versionName ?: "unknown", + deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}", + screenResolution = "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}", + locale = Locale.getDefault().toString(), + ) + } + } + } + + @Serializable + data class SasResponse( + val blobPath: String, + val uploadUrl: String, + val expiresIn: Int, + val maxSizeBytes: Int, + ) + + @Serializable + data class FeedbackResponse( + val id: String, + val productId: String, + val userId: String, + val type: String, + val title: String, + val status: String, + val createdAt: String, + val screenshotBlobPath: String? = null, + ) + + data class FeedbackParams( + val type: FeedbackType, + val title: String, + val body: String? = null, + val screen: String? = null, + val rating: Int? = null, + val screenshot: Pair? = null, + val deviceContext: DeviceContext? = null, + ) + + private val json = Json { ignoreUnknownKeys = true } + private val platformClient = BLPlatformClient(config, tokenProvider) + private val uploadClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Submit feedback with optional screenshot. + * + * TODO-3: Full implementation + */ + suspend fun submitFeedback(params: FeedbackParams): FeedbackResponse? = withContext(Dispatchers.IO) { + try { + // Step 1: Handle screenshot upload if provided + var screenshotMeta: Triple? = null + params.screenshot?.let { (data, format) -> + val contentType = when (format) { + ScreenshotFormat.PNG -> "image/png" + ScreenshotFormat.JPEG -> "image/jpeg" + ScreenshotFormat.WEBP -> "image/webp" + } + + // Get SAS URL + val sas = generateSASUrl(contentType) ?: return@withContext null + + // Upload screenshot + val uploaded = uploadScreenshot(data, sas.uploadUrl, contentType) + if (!uploaded) return@withContext null + + screenshotMeta = Triple(sas.blobPath, contentType, data.size) + } + + // Step 2: Submit feedback + val body = buildMap { + put("type", params.type.name.lowercase()) + put("title", params.title) + params.body?.let { put("body", it) } + params.screen?.let { put("screen", it) } + params.rating?.let { put("rating", it) } + screenshotMeta?.let { (path, type, size) -> + put("screenshotBlobPath", path) + put("screenshotContentType", type) + put("screenshotSizeBytes", size) + } + params.deviceContext?.let { ctx -> + put("deviceContext", mapOf( + "osVersion" to ctx.osVersion, + "appVersion" to ctx.appVersion, + "deviceModel" to ctx.deviceModel, + "screenResolution" to ctx.screenResolution, + "locale" to ctx.locale, + )) + } + } + + // TODO-3: Implement actual API call + throw NotImplementedError( + "submitFeedback API call not yet implemented. " + + "Use platformClient.request(\"POST\", \"/api/feedback\", jsonBody)" + ) + } catch (_: Exception) { + null + } + } + + /** + * Capture screenshot and submit feedback in one operation. + * + * TODO-3: Full implementation using MediaProjection or View.draw() + */ + suspend fun captureAndSubmit( + context: Context, + type: FeedbackType, + title: String, + body: String? = null, + ): FeedbackResponse? { + throw NotImplementedError( + "captureAndSubmit not yet implemented.\n\n" + + "To implement:\n" + + "1. Option A - MediaProjection API (requires permission):\n" + + " - Request MediaProjection permission\n" + + " - Use MediaProjection.createVirtualDisplay()\n" + + " - Capture ImageReader frame\n\n" + + "2. Option B - View.draw() (limited to app window):\n" + + " - val view = window.decorView.rootView\n" + + " - val bitmap = Bitmap.createBitmap(view.width, view.height)\n" + + " - val canvas = Canvas(bitmap)\n" + + " - view.draw(canvas)\n\n" + + "3. Convert Bitmap to ByteArray\n" + + "4. Call submitFeedback with screenshot" + ) + } + + /** + * Capture current screen as Bitmap. + * + * TODO-3: Full implementation + */ + fun captureScreen(): Bitmap { + throw NotImplementedError( + "captureScreen requires MediaProjection API. " + + "See: https://developer.android.com/reference/android/media/projection/MediaProjection" + ) + } + + /** + * Capture specific View as Bitmap. + * + * TODO-3: Full implementation + */ + fun captureView(view: View): Bitmap { + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + view.draw(canvas) + return bitmap + } + + /** + * Convert Bitmap to PNG ByteArray. + */ + fun bitmapToBytes(bitmap: Bitmap, format: ScreenshotFormat = ScreenshotFormat.PNG): ByteArray { + val androidFormat = when (format) { + ScreenshotFormat.PNG -> Bitmap.CompressFormat.PNG + ScreenshotFormat.JPEG -> Bitmap.CompressFormat.JPEG + ScreenshotFormat.WEBP -> Bitmap.CompressFormat.WEBP_LOSSY + } + val stream = ByteArrayOutputStream() + bitmap.compress(androidFormat, 90, stream) + return stream.toByteArray() + } + + // MARK: - Private + + private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("contentType" to contentType), + ) + val response = platformClient.request("POST", "/api/feedback/sas", body) + json.decodeFromString(SasResponse.serializer(), response) + } catch (_: Exception) { + null + } + } + + private suspend fun uploadScreenshot( + data: ByteArray, + sasUrl: String, + contentType: String, + ): Boolean = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(sasUrl) + .put(data.toRequestBody(contentType.toMediaType())) + .header("x-ms-blob-type", "BlockBlob") + .header("x-ms-blob-content-type", contentType) + .build() + + uploadClient.newCall(request).execute().use { response -> + response.isSuccessful + } + } catch (_: Exception) { + false + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt new file mode 100644 index 0000000..318276e --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt @@ -0,0 +1,253 @@ +package com.bytelyst.platform + +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom + +/** + * Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). + * All byte arrays are hex-encoded strings for JSON serialization. + */ +data class BLEncryptedField( + /** Sentinel — always true for encrypted fields. */ + val __encrypted: Boolean = true, + /** Schema version (currently 1). */ + val v: Int = 1, + /** Algorithm identifier. */ + val alg: String = "aes-256-gcm", + /** Ciphertext (hex-encoded). */ + val ct: String, + /** Initialization vector (hex-encoded, 12 bytes = 24 hex chars). */ + val iv: String, + /** GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). */ + val tag: String, + /** DEK identifier — identifies which key was used for encryption. */ + val dekId: String, +) + +/** + * AES-256-GCM field-level encryption. + * + * Produces [BLEncryptedField] objects that are wire-compatible with the + * TypeScript `@bytelyst/field-encrypt` package. Backends and native clients + * can encrypt/decrypt the same fields interchangeably. + * + * Usage: + * ```kotlin + * val key = BLFieldEncrypt.generateKey() + * val encrypted = BLFieldEncrypt.encrypt("sensitive data", key, "dek_user1_notes") + * val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + * ``` + */ +object BLFieldEncrypt { + + private const val ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_SIZE_BYTES = 32 + private const val KEY_SIZE_BITS = KEY_SIZE_BYTES * 8 + private const val IV_SIZE_BYTES = 12 + private const val TAG_SIZE_BITS = 128 + + private val secureRandom = SecureRandom() + + // ── Encrypt ───────────────────────────────────────────── + + /** + * Encrypt a plaintext string with AES-256-GCM. + * + * @param plaintext UTF-8 string to encrypt. + * @param key 32-byte AES secret key. + * @param dekId DEK identifier stored in the output for key lookup on decrypt. + * @param aad Optional additional authenticated data (e.g., "userId:context"). + * @return [BLEncryptedField] with hex-encoded ciphertext, IV, and tag. + * @throws IllegalArgumentException if key size is wrong. + */ + fun encrypt( + plaintext: String, + key: SecretKey, + dekId: String, + aad: String? = null, + ): BLEncryptedField { + require(key.encoded.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" + } + + val iv = ByteArray(IV_SIZE_BYTES).also { secureRandom.nextBytes(it) } + + val cipher = Cipher.getInstance(ALGORITHM) + val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) + cipher.init(Cipher.ENCRYPT_MODE, key, spec) + + if (aad != null) { + cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) + } + + // GCM output = ciphertext || tag (last 16 bytes) + val ciphertextWithTag = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val tagOffset = ciphertextWithTag.size - TAG_SIZE_BITS / 8 + val ct = ciphertextWithTag.copyOfRange(0, tagOffset) + val tag = ciphertextWithTag.copyOfRange(tagOffset, ciphertextWithTag.size) + + return BLEncryptedField( + ct = ct.toHexString(), + iv = iv.toHexString(), + tag = tag.toHexString(), + dekId = dekId, + ) + } + + // ── Decrypt ───────────────────────────────────────────── + + /** + * Decrypt a [BLEncryptedField] back to plaintext. + * + * @param field Encrypted field object (from Cosmos DB or API response). + * @param key 32-byte AES secret key (must match the key used to encrypt). + * @param aad Optional AAD (must match the AAD used during encryption). + * @return Decrypted UTF-8 string. + * @throws javax.crypto.AEADBadTagException if authentication fails (tampered data or wrong key). + */ + fun decrypt( + field: BLEncryptedField, + key: SecretKey, + aad: String? = null, + ): String { + require(key.encoded.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" + } + + val iv = field.iv.hexToByteArray() + val ct = field.ct.hexToByteArray() + val tag = field.tag.hexToByteArray() + + // GCM expects ciphertext || tag as input + val ciphertextWithTag = ct + tag + + val cipher = Cipher.getInstance(ALGORITHM) + val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + if (aad != null) { + cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) + } + + val plaintextBytes = cipher.doFinal(ciphertextWithTag) + return String(plaintextBytes, Charsets.UTF_8) + } + + // ── Key Generation ────────────────────────────────────── + + /** + * Generate a random 32-byte AES-256 secret key. + */ + fun generateKey(): SecretKey { + val keyGen = KeyGenerator.getInstance("AES") + keyGen.init(KEY_SIZE_BITS, secureRandom) + return keyGen.generateKey() + } + + /** + * Create a [SecretKey] from a hex-encoded string (64 hex chars = 32 bytes). + */ + fun keyFromHex(hex: String): SecretKey { + val bytes = hex.hexToByteArray() + require(bytes.size == KEY_SIZE_BYTES) { + "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${bytes.size}" + } + return SecretKeySpec(bytes, "AES") + } + + // ── Secure Store Key Derivation ──────────────────────── + + /** + * Get or create a persistent encryption key in [BLSecureStore]. + * + * On first call, generates a random 32-byte AES-256 key and stores it + * as a hex string in EncryptedSharedPreferences. On subsequent calls, + * loads the existing key. This provides a stable per-device DEK for + * client-side encryption without requiring the backend to provision keys. + * + * @param store The [BLSecureStore] instance for the current app. + * @param account Storage key name (default: `"field_encrypt_dek"`). + * @return A 32-byte [SecretKey] backed by secure storage. + */ + fun getOrCreateKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey { + val existingHex = store.read(account) + if (existingHex != null) { + return keyFromHex(existingHex) + } + + val newKey = generateKey() + val hex = newKey.encoded.toHexString() + store.save(account, hex) + return newKey + } + + /** + * Load an existing encryption key from [BLSecureStore] without creating one. + * + * @param store The [BLSecureStore] instance. + * @param account Storage key name (default: `"field_encrypt_dek"`). + * @return The stored [SecretKey], or `null` if none exists. + */ + fun loadKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey? { + val hex = store.read(account) ?: return null + return try { + keyFromHex(hex) + } catch (_: IllegalArgumentException) { + null + } + } + + /** + * Delete the stored encryption key from [BLSecureStore]. + * + * @param store The [BLSecureStore] instance. + * @param account Storage key name. + * @return `true` if the key was deleted. + */ + fun deleteKey(store: BLSecureStore, account: String = "field_encrypt_dek"): Boolean { + return store.delete(account) + } + + // ── Type Guard ────────────────────────────────────────── + + /** + * Check if a JSON-like map represents an encrypted field. + * Compatible with the TypeScript `isEncryptedField()` type guard. + */ + fun isEncrypted(value: Map?): Boolean { + if (value == null) return false + return value["__encrypted"] == true && + value["v"] != null && + value["alg"] != null && + value["ct"] != null && + value["iv"] != null && + value["tag"] != null && + value["dekId"] != null + } + + /** + * Check if a [BLEncryptedField] is valid. + */ + fun isEncrypted(field: BLEncryptedField?): Boolean { + return field?.__encrypted == true + } +} + +// ── Hex Helpers ───────────────────────────────────────────── + +/** Hex-encode a byte array to a lowercase string. */ +fun ByteArray.toHexString(): String = + joinToString("") { "%02x".format(it) } + +/** Decode a hex-encoded string to a byte array. */ +fun String.hexToByteArray(): ByteArray { + require(length % 2 == 0) { "Hex string must have even length, got $length" } + return ByteArray(length / 2) { i -> + val offset = i * 2 + substring(offset, offset + 2).toInt(16).toByte() + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt new file mode 100644 index 0000000..b05d172 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt @@ -0,0 +1,43 @@ +package com.bytelyst.platform + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URLEncoder + +/** + * Kill switch client for platform-service. + * + * Checks GET /api/settings/kill-switch to determine if the app should be disabled. + * Fail-open: returns [KillSwitchResult.ok] on any error. + * + * Mirrors the Swift BLKillSwitchClient API. + */ +class BLKillSwitchClient( + private val config: BLPlatformConfig, +) { + @Serializable + data class KillSwitchResult( + val disabled: Boolean = false, + val message: String? = null, + ) { + companion object { + fun ok() = KillSwitchResult(disabled = false, message = null) + } + } + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config) + + /** + * Check if the app is disabled. Fail-open on any error. + */ + suspend fun check(): KillSwitchResult { + return try { + val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } + val response = client.request("GET", "/api/settings/kill-switch?productId=${enc(config.productId)}&platform=${enc(config.platform)}", skipAuth = true) + json.decodeFromString(response) + } catch (_: Exception) { + KillSwitchResult.ok() + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt new file mode 100644 index 0000000..417141f --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt @@ -0,0 +1,81 @@ +package com.bytelyst.platform + +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import java.net.URLEncoder + +/** + * License client for platform-service. + * + * Activates and checks license keys via /api/licenses endpoints. + * URL-encodes the license key in the path to handle special characters. + * + * Mirrors the Swift BLLicenseClient API. + */ +class BLLicenseClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + @Serializable + data class LicenseStatus( + val valid: Boolean = false, + val plan: String = "free", + val expiresAt: String? = null, + val message: String? = null, + ) + + @Serializable + data class ActivationResult( + val success: Boolean = false, + val plan: String = "free", + val message: String? = null, + ) + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config, tokenProvider) + + /** + * Activate a license key. + */ + suspend fun activate(licenseKey: String): ActivationResult { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + val body = json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("productId" to config.productId), + ) + val response = client.request("POST", "/api/licenses/$encoded/activate", body) + json.decodeFromString(response) + } catch (e: Exception) { + ActivationResult(success = false, message = e.message) + } + } + + /** + * Check the status of a license key. + */ + suspend fun checkStatus(licenseKey: String): LicenseStatus { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + val response = client.request("GET", "/api/licenses/$encoded/status") + json.decodeFromString(response) + } catch (e: Exception) { + LicenseStatus(valid = false, message = e.message) + } + } + + /** + * Deactivate a license key. + */ + suspend fun deactivate(licenseKey: String): Boolean { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + client.request("POST", "/api/licenses/$encoded/deactivate") + true + } catch (_: Exception) { + false + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt new file mode 100644 index 0000000..a14f596 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt @@ -0,0 +1,132 @@ +package com.bytelyst.platform + +import android.content.Context +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Passkey manager wrapping Android Credential Manager API. + * + * Handles FIDO2/WebAuthn passkey registration and authentication + * by coordinating between the platform-service backend and the + * Android Credential Manager. + * + * Usage: + * ```kotlin + * val manager = BLPasskeyManager(context, authClient) + * // Register a new passkey + * manager.registerPasskey("My Pixel 9") + * // Authenticate with an existing passkey + * val user = manager.authenticateWithPasskey() + * ``` + */ +class BLPasskeyManager( + private val context: Context, + private val authClient: BLAuthClient, +) { + private val credentialManager = CredentialManager.create(context) + private val json get() = authClient.client.json + + /** + * Register a new passkey for the current user. + * + * 1. Fetches registration options from backend + * 2. Invokes Credential Manager to create credential + * 3. Sends attestation response to backend for verification + * + * @param friendlyName Human-readable name for this passkey (e.g. "Pixel 9") + * @throws Exception if any step fails + */ + suspend fun registerPasskey(friendlyName: String) { + // Step 1: Get registration options from backend + val optionsResponse = authClient.client.request( + "POST", + "/api/auth/passkeys/register/options", + ) + + // Step 2: Create credential via Credential Manager + val request = CreatePublicKeyCredentialRequest( + requestJson = optionsResponse, + ) + val result = credentialManager.createCredential(context, request) + val credential = result as? androidx.credentials.CreatePublicKeyCredentialResponse + ?: throw IllegalStateException("Unexpected credential type") + + // Step 3: Send attestation to backend + val attestationJson = credential.registrationResponseJson + // Append friendlyName to the response + val bodyObj = json.decodeFromString(attestationJson) + val mutableMap = bodyObj.toMutableMap() + mutableMap["friendlyName"] = kotlinx.serialization.json.JsonPrimitive(friendlyName) + val body = json.encodeToString(JsonObject.serializer(), JsonObject(mutableMap)) + + authClient.client.request( + "POST", + "/api/auth/passkeys/register/verify", + body, + ) + } + + /** + * Authenticate using an existing passkey. + * + * 1. Fetches authentication options from backend + * 2. Invokes Credential Manager to select and sign with credential + * 3. Sends assertion response to backend for verification + * 4. Returns authenticated user and stores tokens + * + * @return Authenticated user + * @throws Exception if any step fails + */ + suspend fun authenticateWithPasskey(): BLAuthClient.AuthUser { + // Step 1: Get authentication options from backend + val optionsResponse = authClient.client.request( + "POST", + "/api/auth/passkeys/authenticate/options", + skipAuth = true, + ) + + // Step 2: Get credential via Credential Manager + val getRequest = GetCredentialRequest( + listOf(GetPublicKeyCredentialOption(requestJson = optionsResponse)), + ) + val result = credentialManager.getCredential(context, getRequest) + val credential = result.credential as? PublicKeyCredential + ?: throw IllegalStateException("Unexpected credential type") + + // Step 3: Send assertion to backend + val assertionJson = credential.authenticationResponseJson + val response = authClient.client.request( + "POST", + "/api/auth/passkeys/authenticate/verify", + assertionJson, + skipAuth = true, + ) + + // Step 4: Parse tokens and update auth state + val tokenResult = json.decodeFromString(response) + // Use reflection-free approach: directly set tokens + authClient.handleLoginResult(tokenResult) + return tokenResult.user + } + + /** + * List registered passkeys for the current user. + */ + suspend fun listPasskeys(): List { + val response = authClient.client.request("GET", "/api/auth/passkeys") + return json.decodeFromString>(response) + } + + /** + * Delete a passkey (requires step-up authentication). + */ + suspend fun deletePasskey(passkeyId: String) { + authClient.client.request("DELETE", "/api/auth/passkeys/$passkeyId") + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt new file mode 100644 index 0000000..cc8334a --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt @@ -0,0 +1,99 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Generic HTTP client for platform-service. + * + * Injects auth token, x-product-id, and x-request-id on every request. + * Supports fire-and-forget mode for telemetry-style calls. + */ +class BLPlatformClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .build() + + /** + * Execute a request and return the response body as a String. + * Throws on non-2xx responses. + */ + suspend fun request( + method: String, + path: String, + body: String? = null, + extraHeaders: Map = emptyMap(), + skipAuth: Boolean = false, + ): String = withContext(Dispatchers.IO) { + val builder = Request.Builder() + .url("${config.baseUrl}$path") + .header("Content-Type", "application/json") + .header("X-Product-Id", config.productId) + .header("X-Request-Id", UUID.randomUUID().toString()) + + if (!skipAuth) { + tokenProvider()?.let { token -> + builder.header("Authorization", "Bearer $token") + } + } + + extraHeaders.forEach { (k, v) -> builder.header(k, v) } + + val requestBody = body?.toRequestBody("application/json".toMediaType()) + when (method.uppercase()) { + "GET" -> builder.get() + "POST" -> builder.post(requestBody ?: "".toRequestBody(null)) + "PUT" -> builder.put(requestBody ?: "".toRequestBody(null)) + "DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete() + "PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null)) + else -> builder.method(method, requestBody) + } + + val response = httpClient.newCall(builder.build()).execute() + val responseBody = response.body?.string() ?: "" + + if (!response.isSuccessful) { + throw BLApiException(response.code, responseBody) + } + + responseBody + } + + /** + * Fire-and-forget: execute request, silently swallow errors. + */ + suspend fun fireAndForget( + method: String, + path: String, + body: String? = null, + extraHeaders: Map = emptyMap(), + ) { + try { + request(method, path, body, extraHeaders) + } catch (_: Exception) { + // Silently swallow — fire-and-forget + } + } +} + +/** + * Exception thrown when the platform API returns a non-2xx status. + */ +class BLApiException( + val statusCode: Int, + val responseBody: String, +) : Exception("Platform API error $statusCode: $responseBody") diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt new file mode 100644 index 0000000..62520dd --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt @@ -0,0 +1,33 @@ +package com.bytelyst.platform + +/** + * Product-specific configuration for the ByteLyst platform SDK. + * + * Each Android app creates one instance at startup and passes it to + * all SDK components via DI (Hilt, Koin, or manual). + */ +data class BLPlatformConfig( + /** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */ + val productId: String, + + /** Platform-service base URL (e.g., "http://localhost:4003/api"). */ + val baseUrl: String, + + /** Platform string for telemetry (e.g., "android", "wear_os"). */ + val platform: String = "android", + + /** Channel string for telemetry (e.g., "native", "keyboard"). */ + val channel: String = "native", + + /** Application ID / bundle ID (e.g., "com.chronomind.app"). */ + val applicationId: String, + + /** App version string for telemetry headers (e.g., "1.2.0"). */ + val appVersion: String = "0.0.0", + + /** OS version string for telemetry headers (e.g., "14"). */ + val osVersion: String = "unknown", + + /** Request timeout in milliseconds. Default: 15 seconds. */ + val timeoutMs: Long = 15_000L, +) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt new file mode 100644 index 0000000..2550bea --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt @@ -0,0 +1,51 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys + +/** + * EncryptedSharedPreferences-backed secure storage. + * + * Replaces Keychain on iOS. Uses Android Keystore for key material. + * Each app gets its own namespace via [applicationId]. + */ +class BLSecureStore( + context: Context, + applicationId: String, +) { + private val prefs: SharedPreferences = try { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + "${applicationId}_secure_store", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (_: Exception) { + // Fallback to plain SharedPreferences if encryption fails (e.g., test environment) + context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE) + } + + fun save(key: String, value: String): Boolean { + return prefs.edit().putString(key, value).commit() + } + + fun read(key: String): String? { + return prefs.getString(key, null) + } + + fun delete(key: String): Boolean { + return prefs.edit().remove(key).commit() + } + + fun clear(): Boolean { + return prefs.edit().clear().commit() + } + + fun contains(key: String): Boolean { + return prefs.contains(key) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt new file mode 100644 index 0000000..2158714 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt @@ -0,0 +1,367 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Survey Client — In-app survey client for Android. + * Part of ByteLystPlatformSDK. + */ +class BLSurveyClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private var pollingJob: Job? = null + private val responseCache = mutableMapOf() + + enum class QuestionType { + SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, NPS, TEXT_SHORT, TEXT_LONG, DROPDOWN, SCALE, RANKING + } + + @Serializable + data class QuestionOption( + val id: String, + val text: String, + val emoji: String? = null, + ) + + @Serializable + data class Question( + val id: String, + val type: String, + val text: String, + val description: String? = null, + val required: Boolean, + val options: List? = null, + val minLength: Int? = null, + val maxLength: Int? = null, + val minValue: Int? = null, + val maxValue: Int? = null, + ) + + @Serializable + data class SurveyIncentive( + val type: String, + val amount: Int, + ) + + @Serializable + data class SurveyTrigger( + val type: String, + val seconds: Int? = null, + val eventName: String? = null, + val pagePattern: String? = null, + ) + + @Serializable + data class ActiveSurvey( + val id: String, + val title: String, + val description: String? = null, + val questions: List, + val incentive: SurveyIncentive? = null, + val displayTrigger: SurveyTrigger, + ) + + @Serializable + data class SurveyAnswer( + val type: String, + val value: JsonObject, + ) + + @Serializable + data class SurveyResponse( + val id: String, + val surveyId: String, + val userId: String, + val answers: Map, + val currentQuestionIndex: Int, + val startedAt: String, + val completedAt: String? = null, + val isComplete: Boolean, + val incentiveClaimed: Boolean, + val incentiveClaimedAt: String? = null, + val createdAt: String, + val updatedAt: String, + ) + + @Serializable + private data class ActiveSurveyResponse( + val survey: ActiveSurvey?, + ) + + @Serializable + private data class StartSurveyResponse( + val responseId: String, + val startedAt: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + private data class SubmitAnswerResponse( + val responseId: String, + val currentQuestionIndex: Int, + val answers: Map, + ) + + @Serializable + data class SurveyCompletionResult( + val success: Boolean, + val timeSpentSeconds: Int, + val incentiveClaimed: Boolean, + ) + + /** + * Get active survey for the current user (if any). + */ + suspend fun getActiveSurvey(): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest(path = "/surveys/active") + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(ActiveSurveyResponse.serializer(), body) + Result.success(result.survey) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Start a survey session. + */ + suspend fun startSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/start", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(StartSurveyResponse.serializer(), body) + + val surveyResponse = SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = result.startedAt, + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = result.startedAt, + updatedAt = result.startedAt, + ) + + // Cache the response + responseCache[surveyId] = surveyResponse + Result.success(surveyResponse) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Submit an answer to a survey question. + */ + suspend fun submitAnswer( + surveyId: String, + questionId: String, + answer: SurveyAnswer, + ): Result = withContext(Dispatchers.IO) { + try { + val body = json.encodeToString( + SubmitAnswerRequest.serializer(), + SubmitAnswerRequest(questionId, answer) + ) + + val request = buildRequest( + path = "/surveys/$surveyId/response", + method = "POST", + body = body + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SubmitAnswerResponse.serializer(), responseBody) + + // Update cache + val cached = responseCache[surveyId] + if (cached != null) { + val updated = cached.copy( + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + ) + responseCache[surveyId] = updated + } + + Result.success( + SurveyResponse( + id = result.responseId, + surveyId = surveyId, + userId = "", + answers = result.answers, + currentQuestionIndex = result.currentQuestionIndex, + startedAt = "", + completedAt = null, + isComplete = false, + incentiveClaimed = false, + incentiveClaimedAt = null, + createdAt = "", + updatedAt = java.time.Instant.now().toString(), + ) + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Complete a survey. + */ + suspend fun completeSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/complete", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + val result = json.decodeFromString(SurveyCompletionResult.serializer(), body) + + // Clear cache on completion + responseCache.remove(surveyId) + Result.success(result) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Dismiss a survey (won't show again). + */ + suspend fun dismissSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { + try { + val request = buildRequest( + path = "/surveys/$surveyId/dismiss", + method = "POST" + ) + val response = httpClient.newCall(request).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}")) + } + + // Clear cache + responseCache.remove(surveyId) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Get cached response for a survey. + */ + fun getCachedResponse(surveyId: String): SurveyResponse? { + return responseCache[surveyId] + } + + /** + * Start polling for eligible surveys. + */ + fun startPolling( + intervalMs: Long = 60000L, + onUpdate: (ActiveSurvey?) -> Unit, + ) { + stopPolling() + pollingJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + getActiveSurvey() + .onSuccess { survey -> onUpdate(survey) } + .onFailure { /* Silently ignore polling errors */ } + delay(intervalMs) + } + } + } + + /** + * Stop polling for surveys. + */ + fun stopPolling() { + pollingJob?.cancel() + pollingJob = null + } + + @Serializable + private data class SubmitAnswerRequest( + val questionId: String, + val answer: SurveyAnswer, + ) + + private fun buildRequest( + path: String, + method: String = "GET", + body: String? = null, + ): Request { + val url = "${config.baseUrl}$path" + val token = tokenProvider() ?: "" + + val builder = Request.Builder() + .url(url) + .header("Authorization", "Bearer $token") + .header("x-product-id", config.productId) + .header("x-platform", "android") + .header("x-app-version", config.appVersion) + .header("x-os-version", config.osVersion) + + if (body != null) { + builder.method(method, body.toRequestBody("application/json".toMediaType())) + } else if (method != "GET") { + builder.method(method, "".toRequestBody(null)) + } + + return builder.build() + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt new file mode 100644 index 0000000..060cbaf --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt @@ -0,0 +1,111 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Generic offline-first sync engine. + * + * Apps implement [BLSyncAdapter] for their specific data type, + * then [BLSyncEngine] handles the pull-merge-push cycle. + * + * Mirrors the Swift BLSyncEngine + BLSyncAdapter API. + */ +class BLSyncEngine( + private val adapter: BLSyncAdapter, + private val config: BLPlatformConfig, + tokenProvider: () -> String? = { null }, +) { + private val client = BLPlatformClient(config, tokenProvider) + + /** + * Run a full sync cycle: pull → merge → push. + * + * @return [SyncResult] with counts of pulled, pushed, and conflicted items. + */ + suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { + var pulled = 0 + var pushed = 0 + var conflicts = 0 + + try { + // Step 1: Pull remote changes + val lastSync = adapter.getLastSyncTimestamp() + val pullPath = adapter.pullPath(lastSync) + val response = client.request("GET", pullPath) + val remoteItems = adapter.deserializePullResponse(response) + pulled = remoteItems.size + + // Step 2: Merge remote into local + for (item in remoteItems) { + val merged = adapter.merge(item) + if (!merged) conflicts++ + } + + // Step 3: Push local changes + val localChanges = adapter.getLocalChanges() + for (item in localChanges) { + try { + val body = adapter.serializeForPush(item) + val method = adapter.pushMethod(item) + val path = adapter.pushPath(item) + client.request(method, path, body) + adapter.markSynced(item) + pushed++ + } catch (_: Exception) { + // Individual push failure — will retry next sync + } + } + + // Step 4: Update last sync timestamp + adapter.setLastSyncTimestamp(System.currentTimeMillis()) + } catch (_: Exception) { + // Full sync failure — will retry next time + } + + SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts) + } + + data class SyncResult( + val pulled: Int = 0, + val pushed: Int = 0, + val conflicts: Int = 0, + ) +} + +/** + * Adapter interface for [BLSyncEngine]. + * + * Each app implements this for their domain model (timers, sessions, etc.). + */ +interface BLSyncAdapter { + /** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */ + fun getLastSyncTimestamp(): Long? + + /** Set the last sync timestamp after a successful sync. */ + fun setLastSyncTimestamp(timestamp: Long) + + /** Build the pull endpoint path, optionally including a since parameter. */ + fun pullPath(since: Long?): String + + /** Deserialize the pull response JSON into a list of remote items. */ + fun deserializePullResponse(json: String): List + + /** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */ + fun merge(remoteItem: T): Boolean + + /** Get local items that have been modified since last sync. */ + fun getLocalChanges(): List + + /** Serialize a local item for push. */ + fun serializeForPush(item: T): String + + /** HTTP method for pushing an item (POST for new, PUT for update). */ + fun pushMethod(item: T): String + + /** Build the push endpoint path for an item. */ + fun pushPath(item: T): String + + /** Mark an item as synced (clear dirty flag). */ + fun markSynced(item: T) +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt new file mode 100644 index 0000000..35a061d --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt @@ -0,0 +1,195 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +/** + * Telemetry client for platform-service. + * + * Queues events locally and flushes in batches to POST /api/telemetry/events. + * Events persist in SharedPreferences to survive process death. + * Fire-and-forget: errors never surface to the user. + * + * Mirrors the Swift BLTelemetryClient API. + */ +class BLTelemetryClient( + private val config: BLPlatformConfig, + context: Context, + private val maxQueueSize: Int = 50, + private val flushIntervalMs: Long = 30_000L, +) { + // ── Event schema ───────────────────────────────────────── + + @Serializable + data class TelemetryEvent( + val id: String, + val productId: String, + val anonymousInstallId: String, + val sessionId: String, + val platform: String, + val channel: String, + val osFamily: String, + val osVersion: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val eventType: String, + val module: String, + val eventName: String, + val feature: String? = null, + val message: String? = null, + val errorCode: String? = null, + val errorDomain: String? = null, + val tags: Map? = null, + val metrics: Map? = null, + val occurredAt: String, + ) + + @Serializable + private data class EventBatch(val events: List) + + // ── State ──────────────────────────────────────────────── + + private val prefs: SharedPreferences = + context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val queue = mutableListOf() + private var flushJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var sessionId = UUID.randomUUID().toString() + + private val client = BLPlatformClient(config) + + private val installId: String by lazy { + val key = "install_id" + prefs.getString(key, null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString(key, it).apply() + } + } + + private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + private val appVersion: String + private val buildNumber: String + + init { + val pm = context.packageManager + val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null } + appVersion = pi?.versionName ?: "0.0.0" + buildNumber = (pi?.longVersionCode ?: 0).toString() + } + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ + @Synchronized + private fun isoNow(): String = isoFormat.format(Date()) + + // ── Lifecycle ──────────────────────────────────────────── + + fun start() { + if (flushJob != null) return + flushJob = scope.launch { + while (isActive) { + delay(flushIntervalMs) + flush() + } + } + } + + fun stop() { + flush() + flushJob?.cancel() + flushJob = null + } + + fun newSession() { + sessionId = UUID.randomUUID().toString() + } + + // ── Public API ─────────────────────────────────────────── + + fun trackEvent( + eventType: String, + module: String, + name: String, + feature: String? = null, + message: String? = null, + errorCode: String? = null, + errorDomain: String? = null, + tags: Map? = null, + metrics: Map? = null, + ) { + val event = TelemetryEvent( + id = UUID.randomUUID().toString(), + productId = config.productId, + anonymousInstallId = installId, + sessionId = sessionId, + platform = config.platform, + channel = config.channel, + osFamily = "android", + osVersion = osVersion, + appVersion = appVersion, + buildNumber = buildNumber, + releaseChannel = "beta", + eventType = eventType, + module = module, + eventName = name, + feature = feature, + message = message?.take(512), + errorCode = errorCode, + errorDomain = errorDomain, + tags = tags, + metrics = metrics, + occurredAt = isoNow(), + ) + + synchronized(queue) { + queue.add(event) + if (queue.size >= maxQueueSize) { + flush() + } + } + } + + fun trackScreen(screen: String) { + trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen)) + } + + // ── Flush ──────────────────────────────────────────────── + + fun flush() { + val batch: List + synchronized(queue) { + if (queue.isEmpty()) return + batch = queue.toList() + queue.clear() + } + + scope.launch { + val body = json.encodeToString(EventBatch(batch)) + client.fireAndForget( + "POST", "/api/telemetry/events", body, + extraHeaders = mapOf("X-Install-Token" to installId), + ) + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt new file mode 100644 index 0000000..3fd4d4b --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt @@ -0,0 +1,80 @@ +package com.bytelyst.platform + +import android.content.Context + +/** + * Unified entry point for the ByteLyst platform SDK. + * + * Creates and wires all platform services from a single config + context. + * Mirrors the Swift `ByteLystPlatform` API. + * + * Usage: + * ```kotlin + * val platform = ByteLystPlatform( + * context = applicationContext, + * config = BLPlatformConfig( + * productId = "chronomind", + * baseUrl = "https://api.chronomind.app", + * applicationId = "com.chronomind.app" + * ) + * ) + * + * platform.start() + * platform.telemetry.trackScreen("home") + * val isNew = platform.flags.isEnabled("new_feature") + * platform.stop() + * ``` + */ +class ByteLystPlatform( + context: Context, + val config: BLPlatformConfig, +) { + /** Secure storage (EncryptedSharedPreferences). */ + val secureStore: BLSecureStore = BLSecureStore(context, config.applicationId) + + /** HTTP client shared by services that need a token provider. */ + val client: BLPlatformClient = BLPlatformClient(config) { + secureStore.read("access_token") + } + + /** Auth client (login, register, refresh, MFA, etc.). */ + val auth: BLAuthClient = BLAuthClient(config, secureStore) + + /** Telemetry event tracking + batch flush. */ + val telemetry: BLTelemetryClient = BLTelemetryClient(config, context) + + /** Feature flag polling. */ + val flags: BLFeatureFlagClient = BLFeatureFlagClient(config) + + /** Kill switch checker. */ + val killSwitch: BLKillSwitchClient = BLKillSwitchClient(config) + + /** Local rotating audit log. */ + val auditLog: BLAuditLogger = BLAuditLogger(context, config) + + /** Whether [start] has been called. */ + var isStarted: Boolean = false + private set + + /** + * Start all services: telemetry flush timer, feature flag polling. + * Call on app launch / Activity.onCreate. + */ + fun start(userId: String? = null) { + if (isStarted) return + isStarted = true + telemetry.start() + flags.init(userId) + } + + /** + * Stop all services: flush telemetry, stop flag polling. + * Call on app background / Activity.onDestroy. + */ + fun stop() { + if (!isStarted) return + isStarted = false + telemetry.stop() + flags.stop() + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt new file mode 100644 index 0000000..7b5772b --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt @@ -0,0 +1,172 @@ +package com.bytelyst.platform + +import android.net.Uri +import android.util.Log + +/** + * Deep Link Route data class + */ +data class DeepLinkRoute( + val screen: String, + val params: Map = emptyMap() +) + +/** + * Deep link handler type alias + */ +typealias DeepLinkHandler = (DeepLinkRoute) -> Unit + +/** + * Deep Link Router class + * Handles routing from push notification deep links to app screens + */ +class DeepLinkRouter { + private val handlers = mutableMapOf() + private var fallbackHandler: DeepLinkHandler? = null + + companion object { + private const val TAG = "DeepLinkRouter" + } + + /** + * Register a handler for a specific screen + */ + fun register(screen: String, handler: DeepLinkHandler) { + handlers[screen] = handler + } + + /** + * Set a fallback handler for unregistered screens + */ + fun setFallback(handler: DeepLinkHandler) { + fallbackHandler = handler + } + + /** + * Parse a deep link URL and extract route + */ + fun parseDeepLink(urlString: String): DeepLinkRoute? { + return try { + val uri = Uri.parse(urlString) + + // Handle app-specific URLs: myapp://screen/params + if (uri.scheme != "http" && uri.scheme != "https") { + val pathSegments = uri.pathSegments + val screen = pathSegments.firstOrNull() ?: "home" + + val params = mutableMapOf() + uri.queryParameterNames.forEach { key -> + uri.getQueryParameter(key)?.let { value -> + params[key] = value + } + } + + DeepLinkRoute(screen, params) + } + // Handle web URLs with deep link params + else if (uri.getQueryParameter("dl") != null) { + parseDeepLink(uri.getQueryParameter("dl")!!) + } + // Handle path-based routing: /screen/params + else { + val pathSegments = uri.pathSegments + if (pathSegments.isNotEmpty()) { + val screen = pathSegments[0] + + val params = mutableMapOf() + uri.queryParameterNames.forEach { key -> + uri.getQueryParameter(key)?.let { value -> + params[key] = value + } + } + + DeepLinkRoute(screen, params) + } else { + null + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse deep link: $urlString", e) + null + } + } + + /** + * Handle a deep link route + */ + fun handle(route: DeepLinkRoute): Boolean { + val handler = handlers[route.screen] + + return if (handler != null) { + handler(route) + true + } else if (fallbackHandler != null) { + fallbackHandler?.invoke(route) + true + } else { + Log.w(TAG, "No handler for screen: ${route.screen}") + false + } + } + + /** + * Process a deep link URL end-to-end + */ + fun process(urlString: String): Boolean { + val route = parseDeepLink(urlString) + return if (route != null) { + handle(route) + } else { + Log.w(TAG, "Failed to parse deep link: $urlString") + false + } + } +} + +/** + * Create a broadcast deep link URL + */ +fun createBroadcastDeepLink( + baseUrl: String, + screen: String, + params: Map = emptyMap(), + broadcastId: String? = null +): String { + val uriBuilder = Uri.parse(baseUrl).buildUpon() + .path("/$screen") + + params.forEach { (key, value) -> + uriBuilder.appendQueryParameter(key, value) + } + + broadcastId?.let { + uriBuilder.appendQueryParameter("broadcastId", it) + } + + return uriBuilder.build().toString() +} + +/** + * Common deep link screens + */ +object DeepLinkScreens { + // Broadcasts + const val BROADCAST = "broadcast" + const val ANNOUNCEMENTS = "announcements" + + // Surveys + const val SURVEY = "survey" + const val SURVEY_LIST = "surveys" + + // Product-specific + const val SETTINGS = "settings" + const val PROFILE = "profile" + const val UPGRADE = "upgrade" + const val SUPPORT = "support" + + // Fallback + const val HOME = "home" +} + +// Singleton instance for app-wide use +val deepLinkRouter = DeepLinkRouter() diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt new file mode 100644 index 0000000..c8286cd --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt @@ -0,0 +1,74 @@ +package com.bytelyst.platform.diagnostics + +import java.text.SimpleDateFormat +import java.util.* + +/** + * Ring buffer for breadcrumbs with fixed max size + */ +class BreadcrumbTrail(private val maxSize: Int = 100) { + private val breadcrumbs = mutableListOf() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Add a breadcrumb to the trail + */ + @Synchronized + fun add(category: String, message: String, data: Map? = null) { + val breadcrumb = DiagnosticsBreadcrumb( + timestamp = dateFormat.format(Date()), + category = category, + message = message, + data = data + ) + + breadcrumbs.add(breadcrumb) + + // Evict oldest if over limit + if (breadcrumbs.size > maxSize) { + breadcrumbs.removeAt(0) + } + } + + /** + * Get all breadcrumbs (oldest first) + */ + @Synchronized + fun getAll(): List { + return breadcrumbs.toList() + } + + /** + * Get last N breadcrumbs + */ + @Synchronized + fun getLast(n: Int): List { + return breadcrumbs.takeLast(n) + } + + /** + * Get most recent breadcrumb + */ + @Synchronized + fun getMostRecent(): DiagnosticsBreadcrumb? { + return breadcrumbs.lastOrNull() + } + + /** + * Clear all breadcrumbs + */ + @Synchronized + fun clear() { + breadcrumbs.clear() + } + + /** + * Get current size + */ + @Synchronized + fun size(): Int { + return breadcrumbs.size + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt new file mode 100644 index 0000000..4da2ec8 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt @@ -0,0 +1,114 @@ +package com.bytelyst.platform.diagnostics + +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import android.os.Build +import android.os.StatFs + +/** + * Device state collector for Android + */ +object DeviceStateCollector { + + /** + * Collect current device state + */ + fun collect(context: Context): DiagnosticsDeviceState { + return DiagnosticsDeviceState( + memoryMB = getMemoryUsage(context), + batteryLevel = getBatteryLevel(context), + isCharging = getIsCharging(context), + storageMB = getStorageUsage(context), + networkType = getNetworkType(context), + isOnline = getIsOnline(context), + thermalState = null // Android doesn't expose thermal state easily + ) + } + + private fun getMemoryUsage(context: Context): Int? { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + + val runtime = Runtime.getRuntime() + val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) + + return usedMemory.toInt() + } + + private fun getBatteryLevel(context: Context): Float? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + + if (level == -1 || scale == -1) return null + + return level / scale.toFloat() + } + + private fun getIsCharging(context: Context): Boolean? { + val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + ?: return null + + val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + return status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + } + + private fun getStorageUsage(context: Context): Int? { + val stat = StatFs(context.filesDir.path) + val blockSize = stat.blockSizeLong + val availableBlocks = stat.availableBlocksLong + val totalBlocks = stat.blockCountLong + + val usedBytes = (totalBlocks - availableBlocks) * blockSize + return (usedBytes / (1024 * 1024)).toInt() + } + + private fun getNetworkType(context: Context): String? { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return "offline" + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "offline" + + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "unknown" + } + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + when (networkInfo?.type) { + ConnectivityManager.TYPE_WIFI -> "wifi" + ConnectivityManager.TYPE_MOBILE -> "cellular" + ConnectivityManager.TYPE_ETHERNET -> "ethernet" + else -> if (networkInfo?.isConnected == true) "unknown" else "offline" + } + } + } + + private fun getIsOnline(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return true // Assume online if can't determine + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(network) + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo?.isConnected == true + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt new file mode 100644 index 0000000..50c8b2d --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt @@ -0,0 +1,535 @@ +package com.bytelyst.platform.diagnostics + +import android.content.Context +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * Client state + */ +sealed class DiagnosticsClientState { + object Idle : DiagnosticsClientState() + data class Polling(val session: DiagnosticsSession?) : DiagnosticsClientState() + data class Active(val session: DiagnosticsSession) : DiagnosticsClientState() + data class Error(val exception: Throwable) : DiagnosticsClientState() +} + +/** + * Diagnostics client configuration + */ +data class DiagnosticsConfiguration( + val productId: String, + val userId: String? = null, + val anonymousInstallId: String, + val platform: String, + val channel: String, + val osFamily: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val serverUrl: String, + val pollIntervalMs: Long = 5000, + val maxBreadcrumbs: Int = 100, + val captureConsole: Boolean = true, + val captureErrors: Boolean = true, + val captureNetwork: Boolean = true, + val getAuthToken: (suspend () -> String)? = null +) + +/** + * Logger interface + */ +interface DiagnosticsLogger { + fun debug(message: String, metadata: Map? = null) + fun info(message: String, metadata: Map? = null) + fun warn(message: String, metadata: Map? = null) + fun error(message: String, metadata: Map? = null) +} + +/** + * No-op logger + */ +class NoOpDiagnosticsLogger : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) {} + override fun info(message: String, metadata: Map?) {} + override fun warn(message: String, metadata: Map?) {} + override fun error(message: String, metadata: Map?) {} +} + +/** + * Android Log-based logger + */ +class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger { + override fun debug(message: String, metadata: Map?) { + android.util.Log.d(tag, message) + } + override fun info(message: String, metadata: Map?) { + android.util.Log.i(tag, message) + } + override fun warn(message: String, metadata: Map?) { + android.util.Log.w(tag, message) + } + override fun error(message: String, metadata: Map?) { + android.util.Log.e(tag, message) + } +} + +/** + * Main diagnostics client + */ +class DiagnosticsClient private constructor( + private val context: Context, + private val config: DiagnosticsConfiguration, + private val logger: DiagnosticsLogger +) { + companion object { + @Volatile + private var instance: DiagnosticsClient? = null + + fun getInstance( + context: Context, + config: DiagnosticsConfiguration, + logger: DiagnosticsLogger = NoOpDiagnosticsLogger() + ): DiagnosticsClient { + return instance ?: synchronized(this) { + instance ?: DiagnosticsClient(context.applicationContext, config, logger).also { + instance = it + } + } + } + + fun reset() { + instance?.stop() + instance = null + } + } + + private val _state = MutableStateFlow(DiagnosticsClientState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs) + private val logBuffer = mutableListOf() + private val traceBuffer = mutableListOf() + private val networkBuffer = mutableListOf() + + private var pollJob: Job? = null + private var flushJob: Job? = null + private var networkInterceptor: NetworkInterceptor? = null + private var lastEtag: String? = null + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { ignoreUnknownKeys = true } + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + /** + * Start polling for active debug sessions + */ + fun start() { + if (_state.value != DiagnosticsClientState.Idle) { + logger.warn("[diagnostics] Already started") + return + } + + logger.info("[diagnostics] Starting diagnostics client") + _state.value = DiagnosticsClientState.Polling(null) + + // Initial poll + scope.launch { + pollForSession() + } + + // Start polling timer + pollJob = scope.launch { + while (isActive) { + delay(config.pollIntervalMs) + pollForSession() + } + } + + // Start auto-flush timer (every 30 seconds) + flushJob = scope.launch { + while (isActive) { + delay(30000) + flush() + } + } + + // Setup network capture if enabled + if (config.captureNetwork) { + setupNetworkCapture() + } + + breadcrumbs.add(category = "diagnostics", message = "Client started") + } + + /** + * Stop polling and cleanup + */ + fun stop() { + logger.info("[diagnostics] Stopping diagnostics client") + + pollJob?.cancel() + pollJob = null + + flushJob?.cancel() + flushJob = null + + networkInterceptor?.stop() + networkInterceptor = null + + // Final flush + scope.launch { + flush() + } + + _state.value = DiagnosticsClientState.Idle + breadcrumbs.add(category = "diagnostics", message = "Client stopped") + } + + /** + * Check if a debug session is currently active + */ + fun isSessionActive(): Boolean { + return _state.value is DiagnosticsClientState.Active + } + + /** + * Get current session if active + */ + fun getCurrentSession(): DiagnosticsSession? { + return when (val current = _state.value) { + is DiagnosticsClientState.Active -> current.session + is DiagnosticsClientState.Polling -> current.session + else -> null + } + } + + /** + * Record a log entry + */ + fun log( + level: DiagnosticsLogLevel, + message: String, + module: String = "unknown", + file: String? = null, + line: Int? = null, + function: String? = null, + context: Map = emptyMap(), + correlationId: String? = null + ) { + val entry = DiagnosticsLogEntry( + level = level, + message = message, + timestamp = dateFormat.format(Date()), + module = module, + file = file, + line = line, + function = function, + context = context, + correlationId = correlationId + ) + + synchronized(logBuffer) { + logBuffer.add(entry) + } + + breadcrumbs.add( + category = "log", + message = "[${level.name}] ${message.take(100)}", + data = mapOf("level" to level.name) + ) + + // Auto-flush on fatal + if (level == DiagnosticsLogLevel.FATAL) { + scope.launch { flush() } + } + } + + /** + * Record a trace span (auto-instrumented) + */ + suspend fun trace(name: String, operation: suspend () -> T): T { + val spanId = generateId() + val startTime = dateFormat.format(Date()) + + breadcrumbs.add( + category = "trace", + message = "Starting: $name", + data = mapOf("spanId" to spanId) + ) + + return try { + val result = operation() + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.OK + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Completed: $name", + data = mapOf("spanId" to spanId, "durationMs" to durationMs.toString()) + ) + + result + } catch (e: Exception) { + val endTime = dateFormat.format(Date()) + val durationMs = calculateDuration(startTime, endTime) + + val span = DiagnosticsTraceSpan( + spanId = spanId, + name = name, + startTime = startTime, + endTime = endTime, + durationMs = durationMs, + status = DiagnosticsSpanStatus.ERROR, + statusMessage = e.message + ) + + synchronized(traceBuffer) { + traceBuffer.add(span) + } + + breadcrumbs.add( + category = "trace", + message = "Failed: $name", + data = mapOf("spanId" to spanId, "error" to (e.message ?: "Unknown")) + ) + + throw e + } + } + + /** + * Add a manual breadcrumb + */ + fun breadcrumb(category: String, message: String, data: Map? = null) { + breadcrumbs.add(category = category, message = message, data = data) + } + + /** + * Get all breadcrumbs + */ + fun getBreadcrumbs(): List { + return breadcrumbs.getAll() + } + + /** + * Collect and return device state + */ + fun collectDeviceState(): DiagnosticsDeviceState { + return DeviceStateCollector.collect(context) + } + + // Private methods + + private suspend fun pollForSession() { + try { + val url = "${config.serverUrl}/api/diagnostics/config" + + "?productId=${config.productId}" + + "&installId=${config.anonymousInstallId}" + + val requestBuilder = Request.Builder() + .url(url) + .header("Accept", "application/json") + + lastEtag?.let { etag -> + requestBuilder.header("If-None-Match", etag) + } + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token", mapOf("error" to (e.message ?: "unknown"))) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (response.code == 304) { + // No change + return + } + + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + // Store ETag + response.header("ETag")?.let { etag -> + lastEtag = etag + } + + val body = response.body?.string() + val session = body?.let { + try { + json.decodeFromString(it) + } catch (e: Exception) { + null + } + } + + // Update state + if (session != null && session.status == DiagnosticsSessionStatus.ACTIVE) { + if (_state.value !is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session activated", mapOf("sessionId" to session.id)) + breadcrumbs.add( + category = "diagnostics", + message = "Session activated", + data = mapOf("sessionId" to session.id) + ) + } + _state.value = DiagnosticsClientState.Active(session) + } else { + if (_state.value is DiagnosticsClientState.Active) { + logger.info("[diagnostics] Session ended") + breadcrumbs.add(category = "diagnostics", message = "Session ended") + } + _state.value = DiagnosticsClientState.Polling(null) + } + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to poll for session", mapOf("error" to (e.message ?: "unknown"))) + _state.value = DiagnosticsClientState.Error(e) + } + } + + private suspend fun flush() { + val session = getCurrentSession() + if (session == null) { + // No active session, clear buffers + synchronized(logBuffer) { logBuffer.clear() } + synchronized(traceBuffer) { traceBuffer.clear() } + synchronized(networkBuffer) { networkBuffer.clear() } + return + } + + // Build batch + val batch = DiagnosticsIngestBatch( + sessionId = session.id, + traces = synchronized(traceBuffer) { + if (traceBuffer.isEmpty()) null else traceBuffer.take(50).also { + repeat(it.size) { traceBuffer.removeAt(0) } + } + }, + logs = synchronized(logBuffer) { + if (logBuffer.isEmpty()) null else logBuffer.take(50).also { + repeat(it.size) { logBuffer.removeAt(0) } + } + }, + network = synchronized(networkBuffer) { + if (networkBuffer.isEmpty()) null else networkBuffer.take(50).also { + repeat(it.size) { networkBuffer.removeAt(0) } + } + }, + breadcrumbs = breadcrumbs.getAll().takeIf { it.isNotEmpty() }?.also { + breadcrumbs.clear() + } + ) + + // Skip if nothing to send + if (batch.traces == null && batch.logs == null && batch.network == null && batch.breadcrumbs == null) { + return + } + + try { + val url = "${config.serverUrl}/api/diagnostics/ingest" + + val requestBody = json.encodeToString(batch) + .toRequestBody("application/json".toMediaType()) + + val requestBuilder = Request.Builder() + .url(url) + .post(requestBody) + + config.getAuthToken?.let { getToken -> + try { + val token = getToken() + requestBuilder.header("Authorization", "Bearer $token") + } catch (e: Exception) { + logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to (e.message ?: "unknown"))) + } + } + + val request = requestBuilder.build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + + logger.debug( + "[diagnostics] Flushed batch", + mapOf( + "logs" to (batch.logs?.size ?: 0).toString(), + "traces" to (batch.traces?.size ?: 0).toString(), + "network" to (batch.network?.size ?: 0).toString() + ) + ) + } + } catch (e: Exception) { + logger.error("[diagnostics] Failed to flush batch", mapOf("error" to (e.message ?: "unknown"))) + + // Put items back in buffers for retry + synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } + synchronized(traceBuffer) { batch.traces?.let { traceBuffer.addAll(0, it) } } + synchronized(networkBuffer) { batch.network?.let { networkBuffer.addAll(0, it) } } + } + } + + private fun setupNetworkCapture() { + networkInterceptor = NetworkInterceptor { request -> + synchronized(networkBuffer) { + networkBuffer.add(request) + } + } + networkInterceptor?.start(httpClient) + breadcrumbs.add(category = "diagnostics", message = "Network capture enabled") + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } + + private fun calculateDuration(startTime: String, endTime: String): Double { + return try { + val start = dateFormat.parse(startTime)?.time ?: 0 + val end = dateFormat.parse(endTime)?.time ?: 0 + (end - start).toDouble() + } catch (e: Exception) { + 0.0 + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt new file mode 100644 index 0000000..2e63fd1 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt @@ -0,0 +1,152 @@ +package com.bytelyst.platform.diagnostics + +import kotlinx.serialization.Serializable + +/** + * Log severity levels (matches syslog/OpenTelemetry) + */ +enum class DiagnosticsLogLevel { + DEBUG, INFO, WARN, ERROR, FATAL +} + +/** + * Session status from the server + */ +enum class DiagnosticsSessionStatus { + PENDING, ACTIVE, PAUSED, COMPLETED, CANCELLED +} + +/** + * Collection level determines verbosity of captured data + */ +enum class DiagnosticsCollectionLevel { + STANDARD, DEBUG, TRACE +} + +/** + * Diagnostic session configuration from server + */ +@Serializable +data class DiagnosticsSession( + val id: String, + val productId: String, + val status: DiagnosticsSessionStatus, + val collectionLevel: DiagnosticsCollectionLevel, + val captureLogs: Boolean, + val captureNetwork: Boolean, + val captureScreenshots: Boolean, + val screenshotOnError: Boolean, + val maxDurationMinutes: Int, + val createdAt: String, + val expiresAt: String +) + +/** + * Span kind for OpenTelemetry compatibility + */ +enum class DiagnosticsSpanKind { + INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER +} + +/** + * Span status + */ +enum class DiagnosticsSpanStatus { + OK, ERROR, UNSET +} + +/** + * OpenTelemetry-compatible trace span + */ +@Serializable +data class DiagnosticsTraceSpan( + val spanId: String, + val parentId: String? = null, + val name: String, + val kind: DiagnosticsSpanKind? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val attributes: Map = emptyMap(), + val status: DiagnosticsSpanStatus, + val statusMessage: String? = null +) + +/** + * Structured log entry + */ +@Serializable +data class DiagnosticsLogEntry( + val level: DiagnosticsLogLevel, + val message: String, + val timestamp: String, + val module: String, + val file: String? = null, + val line: Int? = null, + val function: String? = null, + val context: Map = emptyMap(), + val correlationId: String? = null +) + +/** + * Breadcrumb for timeline navigation + */ +@Serializable +data class DiagnosticsBreadcrumb( + val timestamp: String, + val category: String, + val message: String, + val data: Map? = null +) + +/** + * Network request/response capture + */ +@Serializable +data class DiagnosticsNetworkRequest( + val id: String, + val url: String, + val method: String, + val requestHeaders: Map = emptyMap(), + val requestBody: String? = null, + val status: Int? = null, + val responseHeaders: Map? = null, + val responseBody: String? = null, + val startTime: String, + val endTime: String? = null, + val durationMs: Double? = null, + val error: String? = null +) + +/** + * Device state snapshot + */ +@Serializable +data class DiagnosticsDeviceState( + val memoryMB: Int? = null, + val batteryLevel: Float? = null, + val isCharging: Boolean? = null, + val storageMB: Int? = null, + val networkType: String? = null, + val isOnline: Boolean, + val thermalState: DiagnosticsThermalState? = null +) + +/** + * Thermal state + */ +enum class DiagnosticsThermalState { + NOMINAL, FAIR, SERIOUS, CRITICAL +} + +/** + * Ingest batch for sending to server + */ +@Serializable +data class DiagnosticsIngestBatch( + val sessionId: String, + val traces: List? = null, + val logs: List? = null, + val breadcrumbs: List? = null, + val network: List? = null +) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt new file mode 100644 index 0000000..3b88b30 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt @@ -0,0 +1,124 @@ +package com.bytelyst.platform.diagnostics + +import okhttp3.* +import okhttp3.Interceptor.Chain +import okio.Buffer +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +/** + * Network interceptor for OkHttp to capture HTTP requests/responses + */ +class NetworkInterceptor( + private val onRequest: (DiagnosticsNetworkRequest) -> Unit +) : Interceptor { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private var isActive = false + private lateinit var httpClient: OkHttpClient + + fun start(client: OkHttpClient) { + this.httpClient = client + isActive = true + } + + fun stop() { + isActive = false + } + + override fun intercept(chain: Chain): Response { + if (!isActive) { + return chain.proceed(chain.request()) + } + + val request = chain.request() + val requestId = generateId() + val startTime = System.currentTimeMillis() + + // Capture request details + val requestHeaders = mutableMapOf() + for (i in 0 until request.headers.size) { + val name = request.headers.name(i) + val value = request.headers.value(i) + requestHeaders[name] = sanitizeHeader(value, name) + } + + val requestBody = request.body?.let { body -> + val buffer = Buffer() + try { + body.writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + null + } + } + + // Proceed with request + val response: Response + try { + response = chain.proceed(request) + } catch (e: Exception) { + // Capture failed request + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), // Limit to 100KB + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date()), + durationMs = (System.currentTimeMillis() - startTime).toDouble(), + error = e.message + ) + onRequest(networkRequest) + throw e + } + + // Capture response + val endTime = System.currentTimeMillis() + val responseHeaders = mutableMapOf() + for (i in 0 until response.headers.size) { + val name = response.headers.name(i) + val value = response.headers.value(i) + responseHeaders[name] = sanitizeHeader(value, name) + } + + val networkRequest = DiagnosticsNetworkRequest( + id = requestId, + url = request.url.toString().take(2048), + method = request.method, + requestHeaders = requestHeaders, + requestBody = requestBody?.take(100 * 1024), + status = response.code, + responseHeaders = responseHeaders, + responseBody = null, // Don't capture response body (too large) + startTime = dateFormat.format(Date(startTime)), + endTime = dateFormat.format(Date(endTime)), + durationMs = (endTime - startTime).toDouble(), + error = null + ) + + onRequest(networkRequest) + + return response + } + + private fun sanitizeHeader(value: String, key: String): String { + val sensitivePatterns = listOf("authorization", "cookie", "token", "api-key") + val lowerKey = key.lowercase() + + for (pattern in sensitivePatterns) { + if (lowerKey.contains(pattern)) { + return "[REDACTED]" + } + } + return value + } + + private fun generateId(): String { + return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt new file mode 100644 index 0000000..e9538a7 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt @@ -0,0 +1,664 @@ +package com.bytelyst.platform.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.bytelyst.platform.BLAuthClient +import kotlinx.coroutines.launch + +// ── Auth UI Configuration ──────────────────────────────────── + +/** OAuth providers supported by BLAuthUI. */ +enum class BLAuthProvider { GOOGLE, MICROSOFT, APPLE } + +/** + * Configuration passed to BLAuthUI screens. + * Host product provides its theme colors and enabled providers. + */ +data class BLAuthUIConfig( + val productName: String = "ByteLyst", + val enabledProviders: List = listOf(BLAuthProvider.GOOGLE, BLAuthProvider.APPLE), +) + +// ── BLLoginScreen ──────────────────────────────────────────── + +/** + * Full login screen with email/password + social buttons + passkey option. + * Host product wraps this in its MaterialTheme for consistent theming. + * + * @param config UI configuration (product name, enabled providers). + * @param onLogin Called with (email, password) when user taps Sign In. + * @param onSocialLogin Called with provider when user taps a social button. + * @param onPasskeyLogin Called when user taps "Use Passkey" (null to hide). + * @param onForgotPassword Called when user taps "Forgot Password?" (null to hide). + * @param onCreateAccount Called when user taps "Create Account" (null to hide). + */ +@Composable +fun BLLoginScreen( + config: BLAuthUIConfig = BLAuthUIConfig(), + onLogin: suspend (String, String) -> Unit, + onSocialLogin: (BLAuthProvider) -> Unit, + onPasskeyLogin: (() -> Unit)? = null, + onForgotPassword: (() -> Unit)? = null, + onCreateAccount: (() -> Unit)? = null, +) { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(48.dp)) + + // Header + Text( + text = "Sign in to ${config.productName}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Welcome back", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(32.dp)) + + // Social Buttons + if (config.enabledProviders.isNotEmpty()) { + config.enabledProviders.forEach { provider -> + SocialButton(provider = provider, onClick = { onSocialLogin(provider) }) + Spacer(Modifier.height(12.dp)) + } + DividerRow() + Spacer(Modifier.height(16.dp)) + } + + // Email / Password + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + // Error + errorMessage?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + } + + // Sign In Button + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + try { + onLogin(email, password) + } catch (e: BLAuthClient.MfaRequiredException) { + // MFA required — handled by caller + } catch (e: Exception) { + errorMessage = e.message ?: "Login failed" + } + isLoading = false + } + }, + enabled = email.isNotBlank() && password.isNotBlank() && !isLoading, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(10.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Sign In") + } + Spacer(Modifier.height(12.dp)) + + // Passkey + if (onPasskeyLogin != null) { + OutlinedButton( + onClick = onPasskeyLogin, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(10.dp), + ) { + Icon(Icons.Default.Key, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Sign in with Passkey") + } + Spacer(Modifier.height(16.dp)) + } + + // Forgot Password / Create Account + if (onForgotPassword != null) { + TextButton(onClick = onForgotPassword) { + Text("Forgot Password?") + } + } + + if (onCreateAccount != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Don't have an account?", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onCreateAccount) { + Text("Create Account") + } + } + } + + Spacer(Modifier.height(32.dp)) + } +} + +// ── BLMfaChallengeScreen ───────────────────────────────────── + +/** + * 6-digit TOTP code entry with recovery code fallback. + * + * @param challenge The MFA challenge from login response. + * @param onVerify Called with (challengeToken, code, method) on submit. + * @param onCancel Called when user cancels. + */ +@Composable +fun BLMfaChallengeScreen( + challenge: BLAuthClient.MfaChallenge, + onVerify: suspend (String, String, String) -> Unit, + onCancel: (() -> Unit)? = null, +) { + var code by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var useRecovery by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + + Text( + text = "Two-Factor Authentication", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + + Text( + text = if (useRecovery) "Enter a recovery code" + else "Enter the 6-digit code from your authenticator app", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(if (useRecovery) "Recovery Code" else "Code") }, + keyboardOptions = KeyboardOptions( + keyboardType = if (useRecovery) KeyboardType.Text else KeyboardType.Number, + ), + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + ), + modifier = Modifier.width(200.dp), + ) + Spacer(Modifier.height(16.dp)) + + errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + val method = if (useRecovery) "recovery" else "totp" + try { + onVerify(challenge.mfaChallenge, code, method) + } catch (e: Exception) { + errorMessage = e.message ?: "Verification failed" + code = "" + } + isLoading = false + } + }, + enabled = code.isNotBlank() && !isLoading, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(10.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Verify") + } + Spacer(Modifier.height(16.dp)) + + TextButton(onClick = { + useRecovery = !useRecovery + code = "" + errorMessage = null + }) { + Text(if (useRecovery) "Use authenticator code" else "Use recovery code") + } + + if (onCancel != null) { + TextButton(onClick = onCancel) { + Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +// ── BLPasskeyScreen ────────────────────────────────────────── + +/** + * Passkey prompt with biometric hint text. + * Triggers Android Credential Manager for passkey authentication. + * + * @param onAuthenticate Called when user taps Continue (triggers passkey flow). + * @param onCancel Called when user taps "Use another method". + */ +@Composable +fun BLPasskeyScreen( + onAuthenticate: suspend () -> Unit, + onCancel: (() -> Unit)? = null, +) { + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + + Text( + text = "Sign in with Passkey", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + + Text( + text = "Use your fingerprint, face, or screen lock to sign in", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + try { + onAuthenticate() + } catch (e: Exception) { + errorMessage = e.message ?: "Passkey authentication failed" + } + isLoading = false + } + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(10.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + } + Icon(Icons.Default.Fingerprint, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text("Continue") + } + Spacer(Modifier.height(16.dp)) + + if (onCancel != null) { + TextButton(onClick = onCancel) { + Text("Use another method") + } + } + } +} + +// ── BLDeviceListScreen ─────────────────────────────────────── + +/** + * Device management screen — list trusted/remembered devices, revoke trust. + * + * @param devices List of devices from BLAuthClient.listDevices(). + * @param onRevokeDevice Called with device ID when user revokes a device. + * @param onRevokeAll Called when user revokes all devices. + * @param isLoading Whether data is loading. + */ +@Composable +fun BLDeviceListScreen( + devices: List, + onRevokeDevice: (String) -> Unit, + onRevokeAll: (() -> Unit)? = null, + isLoading: Boolean = false, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Your Devices", + style = MaterialTheme.typography.titleMedium, + ) + if (onRevokeAll != null && devices.isNotEmpty()) { + TextButton(onClick = onRevokeAll) { + Text("Revoke All", color = MaterialTheme.colorScheme.error) + } + } + } + Spacer(Modifier.height(16.dp)) + + if (isLoading) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (devices.isEmpty()) { + Text( + text = "No devices found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + devices.forEach { device -> + DeviceCard(device = device, onRevoke = { onRevokeDevice(device.fingerprint) }) + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun DeviceCard( + device: BLAuthClient.Device, + onRevoke: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (device.platform) { + "ios" -> Icons.Default.PhoneIphone + "android" -> Icons.Default.PhoneAndroid + "macos", "windows", "linux" -> Icons.Default.Laptop + else -> Icons.Default.Devices + }, + contentDescription = device.platform, + modifier = Modifier.size(32.dp), + tint = when (device.trustLevel) { + "trusted" -> MaterialTheme.colorScheme.primary + "remembered" -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "${device.trustLevel} · ${device.platform}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (device.trustLevel == "trusted" || device.trustLevel == "remembered") { + IconButton(onClick = onRevoke) { + Icon( + Icons.Default.Close, + contentDescription = "Revoke", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +// ── BLStepUpDialog ─────────────────────────────────────────── + +/** + * Re-authentication dialog for sensitive operations. + * Supports password re-entry. + * + * @param reason Description of why re-auth is needed. + * @param onStepUp Called with (method, credential) — returns step-up token. + * @param onComplete Called with the step-up token on success. + * @param onDismiss Called when user dismisses the dialog. + */ +@Composable +fun BLStepUpDialog( + reason: String = "This action requires re-authentication", + onStepUp: suspend (String, String) -> String, + onComplete: (String) -> Unit, + onDismiss: () -> Unit, +) { + var password by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(32.dp)) + }, + title = { Text("Confirm Your Identity") }, + text = { + Column { + Text( + text = reason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + errorMessage?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + Button( + onClick = { + scope.launch { + isLoading = true + errorMessage = null + try { + val token = onStepUp("password", password) + onComplete(token) + onDismiss() + } catch (e: Exception) { + errorMessage = e.message ?: "Verification failed" + } + isLoading = false + } + }, + enabled = password.isNotBlank() && !isLoading, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +// ── Shared Components ──────────────────────────────────────── + +@Composable +private fun SocialButton( + provider: BLAuthProvider, + onClick: () -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(10.dp), + ) { + Icon( + imageVector = when (provider) { + BLAuthProvider.GOOGLE -> Icons.Default.Public + BLAuthProvider.MICROSOFT -> Icons.Default.Business + BLAuthProvider.APPLE -> Icons.Default.Star + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Continue with ${ + when (provider) { + BLAuthProvider.GOOGLE -> "Google" + BLAuthProvider.MICROSOFT -> "Microsoft" + BLAuthProvider.APPLE -> "Apple" + } + }", + ) + } +} + +@Composable +private fun DividerRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = "or", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt new file mode 100644 index 0000000..a69ff9e --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt @@ -0,0 +1,330 @@ +package com.bytelyst.platform.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import com.bytelyst.platform.* +import kotlinx.coroutines.launch + +/** + * In-App Message Banner — Jetpack Compose component for top/bottom banner display. + * Part of ByteLystPlatformSDK. + */ +@Composable +fun InAppMessageBanner( + client: BLBroadcastClient, + position: BannerPosition = BannerPosition.TOP, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var messages by remember { mutableStateOf>(emptyList()) } + var unreadCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(client) { + // Initial load + val result = client.getMessages() + result.onSuccess { list -> + messages = list + unreadCount = messages.count { it.status == "UNREAD" } + } + + // Start polling + client.startPolling(60000L) { updatedMessages -> + messages = updatedMessages + unreadCount = updatedMessages.count { it.status == "UNREAD" } + } + } + + val bannerMessages = messages.filter { + it.status == "UNREAD" && (it.style == "BANNER" || it.style == "TOAST") + } + + if (bannerMessages.isEmpty()) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding( + top = if (position == BannerPosition.TOP) 16.dp else 0.dp, + bottom = if (position == BannerPosition.BOTTOM) 16.dp else 0.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + bannerMessages.forEach { message -> + BannerCard( + message = message, + onDismiss = { + scope.launch { + client.markDismissed(message.id) + messages = messages.filter { it.id != message.id } + } + }, + onTap = { + scope.launch { + client.trackClick(message.id) + message.ctaUrl?.let { url -> + // Open URL + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) + context.startActivity(intent) + } + client.markRead(message.id) + messages = messages.map { + if (it.id == message.id) it.copy(status = "READ") + else it + } + } + } + ) + } + } +} + +enum class BannerPosition { + TOP, BOTTOM +} + +@Composable +private fun BannerCard( + message: InAppMessage, + onDismiss: () -> Unit, + onTap: () -> Unit +) { + val backgroundColor = when (message.priority.uppercase()) { + "URGENT" -> MaterialTheme.colorScheme.errorContainer + "HIGH" -> Color(0xFFFFF3E0) // Orange-ish + else -> MaterialTheme.colorScheme.surface + } + + Card( + modifier = Modifier + .fillMaxWidth() + .shadow(4.dp, RoundedCornerShape(12.dp)) + .clickable { onTap() }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColor) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + if (message.imageUrl != null) { + AsyncImage( + model = message.imageUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = message.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + message.body?.let { body -> + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + if (message.ctaText != null) { + Text( + text = "Tap to open →", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + if (message.dismissible) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Broadcast Modal — Jetpack Compose component for modal/fullscreen broadcast display. + */ +@Composable +fun BroadcastModal( + client: BLBroadcastClient, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var currentMessage by remember { mutableStateOf(null) } + var showDialog by remember { mutableStateOf(false) } + + LaunchedEffect(client) { + // Start polling for modal messages + client.startPolling(30000L) { messages -> + val modalMessages = messages.filter { + it.status == "UNREAD" && (it.style == "MODAL" || it.style == "FULLSCREEN") + } + if (modalMessages.isNotEmpty() && currentMessage == null) { + currentMessage = modalMessages.first() + showDialog = true + } + } + } + + if (showDialog && currentMessage != null) { + val message = currentMessage!! + + Dialog( + onDismissRequest = { + if (message.dismissible) { + scope.launch { + client.markDismissed(message.id) + } + showDialog = false + currentMessage = null + } + } + ) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header with image + if (message.imageUrl != null) { + AsyncImage( + model = message.imageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + } + + Text( + text = message.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.fillMaxWidth() + ) + + message.body?.let { body -> + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (message.dismissible) { + TextButton( + onClick = { + scope.launch { + client.markDismissed(message.id) + } + showDialog = false + currentMessage = null + } + ) { + Text("Dismiss") + } + } + + if (message.ctaText != null) { + Button( + onClick = { + scope.launch { + client.trackClick(message.id) + message.ctaUrl?.let { url -> + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) + context.startActivity(intent) + } + client.markRead(message.id) + } + showDialog = false + currentMessage = null + } + ) { + Text(message.ctaText) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.OpenInNew, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else { + Button( + onClick = { + scope.launch { + client.markRead(message.id) + } + showDialog = false + currentMessage = null + } + ) { + Text("Got it") + } + } + } + } + } + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt new file mode 100644 index 0000000..9fdacab --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt @@ -0,0 +1,775 @@ +package com.bytelyst.platform.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.bytelyst.platform.* +import com.bytelyst.platform.BLSurveyClient.ActiveSurvey +import com.bytelyst.platform.BLSurveyClient.Question +import com.bytelyst.platform.BLSurveyClient.QuestionOption +import com.bytelyst.platform.BLSurveyClient.SurveyAnswer +import com.bytelyst.platform.BLSurveyClient.SurveyIncentive +import com.bytelyst.platform.BLSurveyClient.QuestionType +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Survey Modal — Jetpack Compose component for displaying and completing surveys. + * Part of ByteLystPlatformSDK. + */ +@Composable +fun SurveyModal( + client: BLSurveyClient, + modifier: Modifier = Modifier +) { + val scope = rememberCoroutineScope() + var survey by remember { mutableStateOf(null) } + var currentQuestionIndex by remember { mutableIntStateOf(0) } + var answers by remember { mutableStateOf>(emptyMap()) } + var isComplete by remember { mutableStateOf(false) } + var showCompletion by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } + + // Question state + var selectedOption by remember { mutableStateOf(null) } + var selectedOptions by remember { mutableStateOf>(emptySet()) } + var ratingValue by remember { mutableIntStateOf(0) } + var textAnswer by remember { mutableStateOf("") } + var rankingOrder by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(client) { + // Check for active survey + val result = client.getActiveSurvey() + result.onSuccess { response -> + response?.let { + survey = it + showDialog = true + } + } + + // Start polling + client.startPolling(60000L) { newSurvey -> + if (newSurvey != null && survey == null) { + survey = newSurvey + showDialog = true + } + } + } + + fun doResetSurvey() { + survey = null + currentQuestionIndex = 0 + answers = emptyMap() + isComplete = false + showCompletion = false + resetQuestionState( + onSelectedOption = { selectedOption = it }, + onSelectedOptions = { selectedOptions = it }, + onRating = { ratingValue = it }, + onText = { textAnswer = it }, + onRanking = { rankingOrder = it } + ) + } + + if (showDialog) { + when { + showCompletion -> { + CompletionDialog( + survey = survey, + onDismiss = { + scope.launch { + survey?.let { client.dismissSurvey(it.id) } + } + showDialog = false + doResetSurvey() + } + ) + } + survey != null && currentQuestionIndex < survey!!.questions.size -> { + val question = survey!!.questions[currentQuestionIndex] + QuestionDialog( + survey = survey!!, + question = question, + questionIndex = currentQuestionIndex, + totalQuestions = survey!!.questions.size, + selectedOption = selectedOption, + selectedOptions = selectedOptions, + ratingValue = ratingValue, + textAnswer = textAnswer, + rankingOrder = rankingOrder, + onSelectedOptionChange = { selectedOption = it }, + onSelectedOptionsChange = { selectedOptions = it }, + onRatingChange = { ratingValue = it }, + onTextChange = { textAnswer = it }, + onRankingChange = { rankingOrder = it }, + onSubmit = { + scope.launch { + submitAnswer( + client = client, + survey = survey!!, + question = question, + selectedOption = selectedOption, + selectedOptions = selectedOptions, + ratingValue = ratingValue, + textAnswer = textAnswer, + rankingOrder = rankingOrder, + onSuccess = { newIndex, newAnswers -> + currentQuestionIndex = newIndex + answers = newAnswers + resetQuestionState( + onSelectedOption = { selectedOption = it }, + onSelectedOptions = { selectedOptions = it }, + onRating = { ratingValue = it }, + onText = { textAnswer = it }, + onRanking = { rankingOrder = it } + ) + + if (currentQuestionIndex >= survey!!.questions.size) { + scope.launch { + completeSurvey(client, survey!!.id) { success, _ -> + if (success) { + isComplete = true + showCompletion = true + } + } + } + } + } + ) + } + }, + onSkip = { + if (!question.required) { + scope.launch { + skipQuestion(client, survey!!, question.id) { newIndex -> + currentQuestionIndex = newIndex + resetQuestionState( + onSelectedOption = { selectedOption = it }, + onSelectedOptions = { selectedOptions = it }, + onRating = { ratingValue = it }, + onText = { textAnswer = it }, + onRanking = { rankingOrder = it } + ) + + if (currentQuestionIndex >= survey!!.questions.size) { + scope.launch { + completeSurvey(client, survey!!.id) { success, _ -> + if (success) { + isComplete = true + showCompletion = true + } + } + } + } + } + } + } + }, + onDismiss = { + scope.launch { + survey?.let { client.dismissSurvey(it.id) } + } + showDialog = false + doResetSurvey() + } + ) + } + } + } +} + +private suspend fun submitAnswer( + client: BLSurveyClient, + survey: ActiveSurvey, + question: Question, + selectedOption: String?, + selectedOptions: Set, + ratingValue: Int, + textAnswer: String, + rankingOrder: List, + onSuccess: (Int, Map) -> Unit +) { + val qType = question.type.toQuestionType() + val answer: SurveyAnswer = when (qType) { + QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { + selectedOption?.let { + SurveyAnswer(type = "single_choice", value = JsonObject(mapOf("value" to JsonPrimitive(it)))) + } ?: return + } + QuestionType.MULTIPLE_CHOICE -> { + SurveyAnswer(type = "multiple_choice", value = JsonObject(mapOf("values" to JsonArray(selectedOptions.map { JsonPrimitive(it) })))) + } + QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { + SurveyAnswer(type = "rating", value = JsonObject(mapOf("value" to JsonPrimitive(ratingValue)))) + } + QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { + SurveyAnswer(type = "text", value = JsonObject(mapOf("value" to JsonPrimitive(textAnswer)))) + } + QuestionType.RANKING -> { + SurveyAnswer(type = "ranking", value = JsonObject(mapOf("order" to JsonArray(rankingOrder.map { JsonPrimitive(it) })))) + } + null -> return + } + + val result = client.submitAnswer(survey.id, question.id, answer) + result.onSuccess { response -> + onSuccess(response.currentQuestionIndex, response.answers) + } +} + +private suspend fun skipQuestion( + client: BLSurveyClient, + survey: ActiveSurvey, + questionId: String, + onSuccess: (Int) -> Unit +) { + val answer = SurveyAnswer(type = "skipped", value = JsonObject(emptyMap())) + val result = client.submitAnswer(survey.id, questionId, answer) + result.onSuccess { response -> + onSuccess(response.currentQuestionIndex) + } +} + +private suspend fun completeSurvey( + client: BLSurveyClient, + surveyId: String, + onComplete: (Boolean, Boolean) -> Unit +) { + val result = client.completeSurvey(surveyId) + result.onSuccess { completion -> + onComplete(completion.success, completion.incentiveClaimed) + } +} + +private fun resetQuestionState( + onSelectedOption: (String?) -> Unit, + onSelectedOptions: (Set) -> Unit, + onRating: (Int) -> Unit, + onText: (String) -> Unit, + onRanking: (List) -> Unit +) { + onSelectedOption(null) + onSelectedOptions(emptySet()) + onRating(0) + onText("") + onRanking(emptyList()) +} + +@Composable +private fun QuestionDialog( + survey: ActiveSurvey, + question: Question, + questionIndex: Int, + totalQuestions: Int, + selectedOption: String?, + selectedOptions: Set, + ratingValue: Int, + textAnswer: String, + rankingOrder: List, + onSelectedOptionChange: (String?) -> Unit, + onSelectedOptionsChange: (Set) -> Unit, + onRatingChange: (Int) -> Unit, + onTextChange: (String) -> Unit, + onRankingChange: (List) -> Unit, + onSubmit: () -> Unit, + onSkip: () -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Progress + LinearProgressIndicator( + progress = { (questionIndex + 1) / totalQuestions.toFloat() }, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Question ${questionIndex + 1} of $totalQuestions", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + // Question text + Text( + text = question.text, + style = MaterialTheme.typography.titleMedium + ) + question.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (question.required) { + Text( + text = "Required", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + + // Question input + val qType = question.type.toQuestionType() + when (qType) { + QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { + SingleChoiceInput( + options = question.options ?: emptyList(), + selected = selectedOption, + onSelect = onSelectedOptionChange + ) + } + QuestionType.MULTIPLE_CHOICE -> { + MultipleChoiceInput( + options = question.options ?: emptyList(), + selected = selectedOptions, + onSelect = onSelectedOptionsChange + ) + } + QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { + RatingInput( + minValue = question.minValue ?: if (qType == QuestionType.NPS) 0 else 1, + maxValue = question.maxValue ?: if (qType == QuestionType.NPS) 10 else 5, + rating = ratingValue, + onRatingChange = onRatingChange + ) + } + QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { + TextAnswerInput( + text = textAnswer, + isLong = qType == QuestionType.TEXT_LONG, + maxLength = question.maxLength, + onTextChange = onTextChange + ) + } + QuestionType.RANKING -> { + RankingInput( + options = question.options ?: emptyList(), + order = rankingOrder, + onOrderChange = onRankingChange + ) + } + null -> { /* unknown type */ } + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (!question.required) { + TextButton(onClick = onSkip) { + Text("Skip") + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } + + val canSubmit = when (qType) { + QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> selectedOption != null + QuestionType.MULTIPLE_CHOICE -> selectedOptions.isNotEmpty() + QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> ratingValue > 0 + QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> textAnswer.isNotBlank() + QuestionType.RANKING -> rankingOrder.size == (question.options?.size ?: 0) + null -> false + } + + val isLast = questionIndex == totalQuestions - 1 + Button( + onClick = onSubmit, + enabled = canSubmit + ) { + Text(if (isLast) "Complete" else "Next") + } + } + } + } + } +} + +@Composable +private fun SingleChoiceInput( + options: List, + selected: String?, + onSelect: (String?) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (selected == option.id) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + .clickable { onSelect(option.id) } + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = option.emoji ?: "") + Text( + text = option.text, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = if (selected == option.id) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected == option.id) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun MultipleChoiceInput( + options: List, + selected: Set, + onSelect: (Set) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + val isSelected = selected.contains(option.id) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant + ) + .clickable { + val newSet = if (isSelected) selected - option.id else selected + option.id + onSelect(newSet) + } + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = option.emoji ?: "") + Text( + text = option.text, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = isSelected, + onCheckedChange = { + val newSet = if (it) selected + option.id else selected - option.id + onSelect(newSet) + } + ) + } + } + } +} + +@Composable +private fun RatingInput( + minValue: Int, + maxValue: Int, + rating: Int, + onRatingChange: (Int) -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + (minValue..maxValue).forEach { value -> + Button( + onClick = { onRatingChange(value) }, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = if (rating == value) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (rating == value) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.size(44.dp) + ) { + Text("$value") + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (maxValue == 10) "Not likely" else "Low", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (maxValue == 10) "Very likely" else "High", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun TextAnswerInput( + text: String, + isLong: Boolean, + maxLength: Int?, + onTextChange: (String) -> Unit +) { + Column { + if (isLong) { + OutlinedTextField( + value = text, + onValueChange = { newText -> + maxLength?.let { + if (newText.length <= it) onTextChange(newText) + } ?: onTextChange(newText) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + maxLines = 6, + keyboardOptions = KeyboardOptions.Default + ) + } else { + OutlinedTextField( + value = text, + onValueChange = { newText -> + maxLength?.let { + if (newText.length <= it) onTextChange(newText) + } ?: onTextChange(newText) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions.Default + ) + } + maxLength?.let { + Text( + text = "${text.length}/$it", + style = MaterialTheme.typography.labelSmall, + color = if (text.length > it) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End) + ) + } + } +} + +@Composable +private fun RankingInput( + options: List, + order: List, + onOrderChange: (List) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { option -> + val rank = order.indexOf(option.id).let { if (it >= 0) it + 1 else null } + val isRanked = rank != null + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Rank badge + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background( + if (isRanked) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface + ), + contentAlignment = Alignment.Center + ) { + Text( + text = rank?.toString() ?: "-", + style = MaterialTheme.typography.labelSmall, + color = if (isRanked) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = option.text, + modifier = Modifier.weight(1f) + ) + + // Controls + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { + val idx = order.indexOf(option.id) + if (idx > 0) { + val newOrder = order.toMutableList() + newOrder.swap(idx, idx - 1) + onOrderChange(newOrder) + } + }, + enabled = isRanked && rank!! > 1, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp)) + } + + IconButton( + onClick = { + val idx = order.indexOf(option.id) + if (idx < order.size - 1) { + val newOrder = order.toMutableList() + newOrder.swap(idx, idx + 1) + onOrderChange(newOrder) + } + }, + enabled = isRanked && rank!! < order.size, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp)) + } + + IconButton( + onClick = { + if (!isRanked) { + onOrderChange(order + option.id) + } + }, + enabled = !isRanked, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) + } + } + } + } + } +} + +private fun String.toQuestionType(): QuestionType? = + QuestionType.entries.firstOrNull { it.name.equals(this, ignoreCase = true) } + +private fun MutableList.swap(i: Int, j: Int) { + val temp = this[i] + this[i] = this[j] + this[j] = temp +} + +@Composable +private fun CompletionDialog( + survey: ActiveSurvey?, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color(0xFF4CAF50) + ) + + Text( + text = "Thank You!", + style = MaterialTheme.typography.headlineSmall + ) + + Text( + text = "Your feedback helps us improve.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + survey?.incentive?.let { incentive: SurveyIncentive -> + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFFE8F5E9) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50) + ) + Text( + text = "You've earned ${incentive.amount} ${if (incentive.type == "pro_days") "Pro Days" else "Credits"}!", + style = MaterialTheme.typography.titleSmall, + color = Color(0xFF2E7D32) + ) + } + } + } + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth() + ) { + Text("Close") + } + } + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt new file mode 100644 index 0000000..82c1722 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt @@ -0,0 +1,178 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * SmartAuth v2 tests for Kotlin BLAuthClient. + * Uses MockWebServer to verify social login, MFA, and type serialization. + */ +class BLAuthClientSmartAuthTest { + + private lateinit var server: MockWebServer + private lateinit var config: BLPlatformConfig + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + config = BLPlatformConfig( + productId = "testapp", + baseUrl = server.url("/api").toString().trimEnd('/'), + platform = "android", + channel = "test", + applicationId = "com.test.smartauth", + ) + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + // ── Test: Social Login (Google) calls correct endpoint ─── + + @Test + fun `loginWithGoogle sends idToken to correct endpoint`() = runTest { + // Arrange: mock token response + val tokenResponse = """ + { + "accessToken": "at_google_123", + "refreshToken": "rt_google_456", + "user": { + "id": "usr_g1", + "email": "google@test.com", + "displayName": "Google User", + "plan": "free", + "role": "user" + } + } + """.trimIndent() + server.enqueue(MockResponse().setBody(tokenResponse).setResponseCode(200)) + + // Act + val client = BLPlatformClient(config) { null } + // We can't easily use BLAuthClient directly (needs BLSecureStore with Context), + // so we test the HTTP layer directly via BLPlatformClient + val body = json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("idToken" to "mock_google_id_token"), + ) + val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) + + // Assert: correct endpoint called + val request = server.takeRequest() + assertEquals("POST", request.method) + assertTrue(request.path!!.contains("/api/auth/oauth/google")) + + // Assert: request body contains idToken + val requestBody = request.body.readUtf8() + assertTrue(requestBody.contains("mock_google_id_token")) + + // Assert: response parses correctly + val result = json.decodeFromString(response) + assertEquals("at_google_123", result.accessToken) + assertEquals("usr_g1", result.user.id) + assertEquals("google@test.com", result.user.email) + } + + // ── Test: MFA challenge detection ──────────────────────── + + @Test + fun `social login detects MFA challenge response`() = runTest { + // Arrange: mock MFA challenge response + val mfaResponse = """ + { + "mfaRequired": true, + "mfaChallenge": "ch_abc123", + "methods": ["totp", "recovery"] + } + """.trimIndent() + server.enqueue(MockResponse().setBody(mfaResponse).setResponseCode(200)) + + // Act: call the endpoint + val client = BLPlatformClient(config) { null } + val body = json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + mapOf("idToken" to "mock_token"), + ) + val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) + + // Assert: response is parseable as MfaChallenge + val challenge = json.decodeFromString(response) + assertTrue(challenge.mfaRequired) + assertEquals("ch_abc123", challenge.mfaChallenge) + assertEquals(listOf("totp", "recovery"), challenge.methods) + } + + // ── Test: SmartAuth types serialize/deserialize ────────── + + @Test + fun `SmartAuth types are correctly serializable`() { + // MfaStatus + val statusJson = """{"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6}""" + val status = json.decodeFromString(statusJson) + assertEquals(true, status.mfaEnabled) + assertEquals(6, status.recoveryCodesRemaining) + + // AuthProvider + val providerJson = """{"provider":"google","email":"test@test.com","linkedAt":"2026-01-01","lastUsedAt":null}""" + val provider = json.decodeFromString(providerJson) + assertEquals("google", provider.provider) + assertNull(provider.lastUsedAt) + + // Device + val deviceJson = """{"fingerprint":"fp_d1","trustLevel":"trusted","trustExpiresAt":"2026-06-01","createdAt":"2026-01-01","lastSeenAt":"2026-03-01","isTrusted":true}""" + val device = json.decodeFromString(deviceJson) + assertEquals("trusted", device.trustLevel) + + // LoginEvent + val eventJson = """{"id":"e1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01"}""" + val event = json.decodeFromString(eventJson) + assertEquals(15, event.riskScore) + assertEquals("SF", event.geo?.city) + + // TotpSetup + val totpJson = """{"otpauthUri":"otpauth://totp/test","qrCode":"data:image/png;base64,abc","recoveryCodes":["code1","code2"]}""" + val totp = json.decodeFromString(totpJson) + assertEquals(2, totp.recoveryCodes.size) + + // StepUpResponse + val stepUpJson = """{"stepUpToken":"su_abc123"}""" + val stepUp = json.decodeFromString(stepUpJson) + assertEquals("su_abc123", stepUp.stepUpToken) + } + + @Test + fun `MfaRequiredException contains challenge data`() { + val challenge = BLAuthClient.MfaChallenge( + mfaRequired = true, + mfaChallenge = "ch_test", + methods = listOf("totp"), + ) + val exception = BLAuthClient.MfaRequiredException(challenge) + assertEquals("MFA required", exception.message) + assertEquals("ch_test", exception.challenge.mfaChallenge) + } + + @Test + fun `AuthState MfaRequired holds challenge`() { + val challenge = BLAuthClient.MfaChallenge( + mfaRequired = true, + mfaChallenge = "ch_xyz", + methods = listOf("totp", "recovery"), + ) + val state = BLAuthClient.AuthState.MfaRequired(challenge) + assertTrue(state is BLAuthClient.AuthState.MfaRequired) + assertEquals("ch_xyz", (state as BLAuthClient.AuthState.MfaRequired).challenge.mfaChallenge) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt new file mode 100644 index 0000000..050a2ff --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt @@ -0,0 +1,134 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BLFeatureFlagClientTest { + + private lateinit var server: MockWebServer + private lateinit var config: BLPlatformConfig + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + config = BLPlatformConfig( + productId = "testapp", + baseUrl = server.url("/api").toString().trimEnd('/'), + applicationId = "com.test.app", + ) + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `isEnabled returns false before init`() { + val client = BLFeatureFlagClient(config) + assertFalse(client.isEnabled("any_flag")) + } + + @Test + fun `getAllFlags returns empty map before init`() { + val client = BLFeatureFlagClient(config) + assertTrue(client.getAllFlags().isEmpty()) + } + + @Test + fun `refresh fetches flags from server`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"flags":{"dark_mode":true,"beta_feature":false}}""") + .setResponseCode(200) + ) + + val client = BLFeatureFlagClient(config) + client.refresh() + + assertTrue(client.isEnabled("dark_mode")) + assertFalse(client.isEnabled("beta_feature")) + assertFalse(client.isEnabled("unknown_flag")) + } + + @Test + fun `getAllFlags returns current flags after refresh`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"flags":{"a":true,"b":false}}""") + .setResponseCode(200) + ) + + val client = BLFeatureFlagClient(config) + client.refresh() + + val flags = client.getAllFlags() + assertEquals(2, flags.size) + assertTrue(flags["a"] == true) + assertTrue(flags["b"] == false) + } + + @Test + fun `keeps existing flags on server error`() = runTest { + // First: successful fetch + server.enqueue( + MockResponse() + .setBody("""{"flags":{"feature_x":true}}""") + .setResponseCode(200) + ) + // Second: server error + server.enqueue(MockResponse().setResponseCode(500)) + + val client = BLFeatureFlagClient(config) + client.refresh() + assertTrue(client.isEnabled("feature_x")) + + client.refresh() + // Should still have the old flags + assertTrue(client.isEnabled("feature_x")) + } + + @Test + fun `sends platform query parameter`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"flags":{}}""") + .setResponseCode(200) + ) + + val client = BLFeatureFlagClient(config) + client.refresh() + + val recorded = server.takeRequest() + assertTrue(recorded.path!!.contains("platform=android")) + } + + @Test + fun `sends X-Product-Id header`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"flags":{}}""") + .setResponseCode(200) + ) + + val client = BLFeatureFlagClient(config) + client.refresh() + + val recorded = server.takeRequest() + assertEquals("testapp", recorded.getHeader("X-Product-Id")) + } + + @Test + fun `stop cancels polling`() { + val client = BLFeatureFlagClient(config, pollIntervalMs = 100_000L) + client.init() + client.stop() + // No assertions needed — verifies stop() doesn't throw + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt new file mode 100644 index 0000000..969cc1a --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt @@ -0,0 +1,206 @@ +package com.bytelyst.platform + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertThrows +import javax.crypto.AEADBadTagException +import javax.crypto.SecretKey + +class BLFieldEncryptTest { + + private lateinit var key: SecretKey + private val dekId = "dek_user1_test" + + @BeforeEach + fun setUp() { + key = BLFieldEncrypt.generateKey() + } + + // ── Encrypt / Decrypt Roundtrip ───────────────────────── + + @Test + fun `encrypt decrypt roundtrip`() { + val plaintext = "Hello, World!" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt empty string`() { + val plaintext = "" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt unicode`() { + val plaintext = "こんにちは世界 \uD83C\uDF0D مرحبا Ñoño" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + @Test + fun `encrypt decrypt large payload`() { + val plaintext = "A".repeat(100_000) + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key) + assertEquals(plaintext, decrypted) + } + + // ── EncryptedField Structure ──────────────────────────── + + @Test + fun `encrypted field has correct sentinel`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + assertTrue(encrypted.__encrypted) + assertEquals(1, encrypted.v) + assertEquals("aes-256-gcm", encrypted.alg) + assertEquals(dekId, encrypted.dekId) + } + + @Test + fun `encrypted field has correct hex lengths`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + // IV: 12 bytes = 24 hex chars + assertEquals(24, encrypted.iv.length) + // Tag: 16 bytes = 32 hex chars + assertEquals(32, encrypted.tag.length) + // ct should be non-empty + assertTrue(encrypted.ct.isNotEmpty()) + } + + @Test + fun `each encryption produces unique IV`() { + val a = BLFieldEncrypt.encrypt("same", key, dekId) + val b = BLFieldEncrypt.encrypt("same", key, dekId) + assertNotEquals(a.iv, b.iv, "Each encryption should use a unique IV") + assertNotEquals(a.ct, b.ct, "Ciphertext should differ with different IVs") + } + + // ── AAD (Additional Authenticated Data) ───────────────── + + @Test + fun `encrypt decrypt with AAD`() { + val plaintext = "secret data" + val aad = "user123:notes" + val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId, aad) + val decrypted = BLFieldEncrypt.decrypt(encrypted, key, aad) + assertEquals(plaintext, decrypted) + } + + @Test + fun `decrypt with wrong AAD fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "correct") + assertThrows { + BLFieldEncrypt.decrypt(encrypted, key, "wrong") + } + } + + @Test + fun `decrypt with missing AAD fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "some-aad") + assertThrows { + BLFieldEncrypt.decrypt(encrypted, key) + } + } + + // ── Wrong Key ─────────────────────────────────────────── + + @Test + fun `decrypt with wrong key fails`() { + val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId) + val wrongKey = BLFieldEncrypt.generateKey() + assertThrows { + BLFieldEncrypt.decrypt(encrypted, wrongKey) + } + } + + // ── Key Size Validation ───────────────────────────────── + + @Test + fun `encrypt rejects short key`() { + val shortKeyBytes = ByteArray(16) // 128-bit instead of 256-bit + val shortKey = javax.crypto.spec.SecretKeySpec(shortKeyBytes, "AES") + assertThrows { + BLFieldEncrypt.encrypt("test", shortKey, dekId) + } + } + + // ── Key from Hex ──────────────────────────────────────── + + @Test + fun `key from hex`() { + val hex = "ab".repeat(32) // 64 hex chars = 32 bytes + val hexKey = BLFieldEncrypt.keyFromHex(hex) + val encrypted = BLFieldEncrypt.encrypt("test", hexKey, dekId) + val decrypted = BLFieldEncrypt.decrypt(encrypted, hexKey) + assertEquals("test", decrypted) + } + + @Test + fun `key from hex rejects invalid length`() { + assertThrows { + BLFieldEncrypt.keyFromHex("aabb") + } + } + + // ── isEncrypted Type Guard ────────────────────────────── + + @Test + fun `isEncrypted with valid field`() { + val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) + assertTrue(BLFieldEncrypt.isEncrypted(encrypted)) + } + + @Test + fun `isEncrypted with null field`() { + assertFalse(BLFieldEncrypt.isEncrypted(null as BLEncryptedField?)) + } + + @Test + fun `isEncrypted with map`() { + val map = mapOf( + "__encrypted" to true, + "v" to 1, + "alg" to "aes-256-gcm", + "ct" to "abcd", + "iv" to "1234", + "tag" to "5678", + "dekId" to "dek_test", + ) + assertTrue(BLFieldEncrypt.isEncrypted(map)) + } + + @Test + fun `isEncrypted with incomplete map`() { + val map = mapOf("__encrypted" to true, "v" to 1) + assertFalse(BLFieldEncrypt.isEncrypted(map)) + } + + @Test + fun `isEncrypted with null map`() { + assertFalse(BLFieldEncrypt.isEncrypted(null as Map?)) + } + + // ── Hex Helpers ───────────────────────────────────────── + + @Test + fun `byte array hex roundtrip`() { + val original = byteArrayOf(0x00, 0x0f, 0xff.toByte(), 0xab.toByte(), 0xcd.toByte()) + val hex = original.toHexString() + assertEquals("000fffabcd", hex) + val restored = hex.hexToByteArray() + assertArrayEquals(original, restored) + } + + @Test + fun `hex to byte array rejects odd length`() { + assertThrows { + "a".hexToByteArray() + } + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt new file mode 100644 index 0000000..0f2438d --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt @@ -0,0 +1,113 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BLKillSwitchClientTest { + + private lateinit var server: MockWebServer + private lateinit var config: BLPlatformConfig + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + config = BLPlatformConfig( + productId = "testapp", + baseUrl = server.url("/api").toString().trimEnd('/'), + applicationId = "com.test.app", + ) + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `returns ok when not disabled`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"disabled":false}""") + .setResponseCode(200) + ) + + val client = BLKillSwitchClient(config) + val result = client.check() + + assertFalse(result.disabled) + assertNull(result.message) + } + + @Test + fun `returns disabled with message`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"disabled":true,"message":"Maintenance in progress"}""") + .setResponseCode(200) + ) + + val client = BLKillSwitchClient(config) + val result = client.check() + + assertTrue(result.disabled) + assertEquals("Maintenance in progress", result.message) + } + + @Test + fun `fail-open on server error`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + + val client = BLKillSwitchClient(config) + val result = client.check() + + assertFalse(result.disabled) + assertNull(result.message) + } + + @Test + fun `fail-open on network error`() = runTest { + server.shutdown() // Force connection failure + + val client = BLKillSwitchClient(config) + val result = client.check() + + assertFalse(result.disabled) + } + + @Test + fun `sends correct query parameters`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"disabled":false}""") + .setResponseCode(200) + ) + + val client = BLKillSwitchClient(config) + client.check() + + val recorded = server.takeRequest() + assertTrue(recorded.path!!.contains("platform=android")) + assertEquals("GET", recorded.method) + } + + @Test + fun `sends X-Product-Id header`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"disabled":false}""") + .setResponseCode(200) + ) + + val client = BLKillSwitchClient(config) + client.check() + + val recorded = server.takeRequest() + assertEquals("testapp", recorded.getHeader("X-Product-Id")) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt new file mode 100644 index 0000000..bf1825c --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt @@ -0,0 +1,39 @@ +package com.bytelyst.platform + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class BLKillSwitchResultTest { + + @Test + fun `ok() should return not-disabled result`() { + val result = BLKillSwitchClient.KillSwitchResult.ok() + assertFalse(result.disabled) + assertNull(result.message) + } + + @Test + fun `disabled result should have message`() { + val result = BLKillSwitchClient.KillSwitchResult( + disabled = true, + message = "Under maintenance" + ) + assertTrue(result.disabled) + assertEquals("Under maintenance", result.message) + } + + @Test + fun `default result should not be disabled`() { + val result = BLKillSwitchClient.KillSwitchResult() + assertFalse(result.disabled) + assertNull(result.message) + } + + @Test + fun `data class copy should work`() { + val original = BLKillSwitchClient.KillSwitchResult.ok() + val modified = original.copy(disabled = true, message = "Updating") + assertTrue(modified.disabled) + assertEquals("Updating", modified.message) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt new file mode 100644 index 0000000..8108801 --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt @@ -0,0 +1,137 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BLLicenseClientTest { + + private lateinit var server: MockWebServer + private lateinit var config: BLPlatformConfig + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + config = BLPlatformConfig( + productId = "testapp", + baseUrl = server.url("/api").toString().trimEnd('/'), + applicationId = "com.test.app", + ) + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `activate returns success result`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"success":true,"plan":"pro","message":"Activated"}""") + .setResponseCode(200) + ) + + val client = BLLicenseClient(config) + val result = client.activate("LYSNR-ABCD-1234") + + assertTrue(result.success) + assertEquals("pro", result.plan) + assertEquals("Activated", result.message) + } + + @Test + fun `activate URL-encodes the license key`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"success":true,"plan":"pro"}""") + .setResponseCode(200) + ) + + val client = BLLicenseClient(config) + client.activate("KEY+WITH SPACE") + + val recorded = server.takeRequest() + assertTrue(recorded.path!!.contains("KEY%2BWITH+SPACE") || recorded.path!!.contains("KEY%2BWITH%20SPACE")) + } + + @Test + fun `activate sends productId in body`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"success":true,"plan":"free"}""") + .setResponseCode(200) + ) + + val client = BLLicenseClient(config) + client.activate("TEST-KEY") + + val recorded = server.takeRequest() + assertTrue(recorded.body.readUtf8().contains("testapp")) + } + + @Test + fun `activate returns failure on error`() = runTest { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid"}""")) + + val client = BLLicenseClient(config) + val result = client.activate("BAD-KEY") + + assertFalse(result.success) + assertNotNull(result.message) + } + + @Test + fun `checkStatus returns valid license`() = runTest { + server.enqueue( + MockResponse() + .setBody("""{"valid":true,"plan":"pro","expiresAt":"2027-01-01"}""") + .setResponseCode(200) + ) + + val client = BLLicenseClient(config) + val result = client.checkStatus("LYSNR-ABCD-1234") + + assertTrue(result.valid) + assertEquals("pro", result.plan) + assertEquals("2027-01-01", result.expiresAt) + } + + @Test + fun `checkStatus returns invalid on error`() = runTest { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not found"}""")) + + val client = BLLicenseClient(config) + val result = client.checkStatus("UNKNOWN") + + assertFalse(result.valid) + } + + @Test + fun `deactivate returns true on success`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLLicenseClient(config) + val result = client.deactivate("LYSNR-ABCD-1234") + + assertTrue(result) + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertTrue(recorded.path!!.contains("deactivate")) + } + + @Test + fun `deactivate returns false on error`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + + val client = BLLicenseClient(config) + val result = client.deactivate("LYSNR-ABCD-1234") + + assertFalse(result) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt new file mode 100644 index 0000000..c7e801f --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt @@ -0,0 +1,175 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BLPlatformClientTest { + + private lateinit var server: MockWebServer + private lateinit var config: BLPlatformConfig + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + config = BLPlatformConfig( + productId = "testapp", + baseUrl = server.url("/api").toString().trimEnd('/'), + applicationId = "com.test.app", + timeoutMs = 5_000L, + ) + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + @Test + fun `GET request returns response body`() = runTest { + server.enqueue(MockResponse().setBody("""{"ok":true}""").setResponseCode(200)) + + val client = BLPlatformClient(config) + val result = client.request("GET", "/health") + + assertEquals("""{"ok":true}""", result) + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertTrue(recorded.path!!.endsWith("/api/health")) + } + + @Test + fun `POST request sends body`() = runTest { + server.enqueue(MockResponse().setBody("""{"id":"1"}""").setResponseCode(201)) + + val client = BLPlatformClient(config) + val result = client.request("POST", "/items", body = """{"name":"test"}""") + + assertEquals("""{"id":"1"}""", result) + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals("""{"name":"test"}""", recorded.body.readUtf8()) + } + + @Test + fun `injects X-Product-Id header`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) + client.request("GET", "/test") + + val recorded = server.takeRequest() + assertEquals("testapp", recorded.getHeader("X-Product-Id")) + } + + @Test + fun `injects X-Request-Id header`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) + client.request("GET", "/test") + + val recorded = server.takeRequest() + val requestId = recorded.getHeader("X-Request-Id") + assertNotNull(requestId) + assertTrue(requestId!!.isNotBlank()) + } + + @Test + fun `injects Authorization header when token provided`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) { "my-token-123" } + client.request("GET", "/test") + + val recorded = server.takeRequest() + assertEquals("Bearer my-token-123", recorded.getHeader("Authorization")) + } + + @Test + fun `omits Authorization header when no token`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) { null } + client.request("GET", "/test") + + val recorded = server.takeRequest() + assertNull(recorded.getHeader("Authorization")) + } + + @Test + fun `skips auth when skipAuth is true`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) { "should-not-appear" } + client.request("GET", "/test", skipAuth = true) + + val recorded = server.takeRequest() + assertNull(recorded.getHeader("Authorization")) + } + + @Test + fun `throws BLApiException on non-2xx`() = runTest { + server.enqueue(MockResponse().setBody("""{"error":"not found"}""").setResponseCode(404)) + + val client = BLPlatformClient(config) + val ex = assertThrows(BLApiException::class.java) { + kotlinx.coroutines.runBlocking { + client.request("GET", "/missing") + } + } + assertEquals(404, ex.statusCode) + assertTrue(ex.responseBody.contains("not found")) + } + + @Test + fun `fireAndForget swallows errors`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + + val client = BLPlatformClient(config) + // Should not throw + client.fireAndForget("POST", "/telemetry", body = """{"event":"test"}""") + + assertEquals(1, server.requestCount) + } + + @Test + fun `PUT request works`() = runTest { + server.enqueue(MockResponse().setBody("""{"updated":true}""").setResponseCode(200)) + + val client = BLPlatformClient(config) + val result = client.request("PUT", "/items/1", body = """{"name":"updated"}""") + + assertEquals("""{"updated":true}""", result) + val recorded = server.takeRequest() + assertEquals("PUT", recorded.method) + } + + @Test + fun `DELETE request works`() = runTest { + server.enqueue(MockResponse().setBody("").setResponseCode(204)) + + val client = BLPlatformClient(config) + val result = client.request("DELETE", "/items/1") + + assertEquals("", result) + val recorded = server.takeRequest() + assertEquals("DELETE", recorded.method) + } + + @Test + fun `extra headers are sent`() = runTest { + server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) + + val client = BLPlatformClient(config) + client.request("GET", "/test", extraHeaders = mapOf("X-Custom" to "value")) + + val recorded = server.takeRequest() + assertEquals("value", recorded.getHeader("X-Custom")) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt new file mode 100644 index 0000000..b40ba6e --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt @@ -0,0 +1,62 @@ +package com.bytelyst.platform + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class BLPlatformConfigTest { + + @Test + fun `should create config with required fields`() { + val config = BLPlatformConfig( + productId = "testapp", + baseUrl = "https://api.test.com/api", + applicationId = "com.test.app", + ) + assertEquals("testapp", config.productId) + assertEquals("https://api.test.com/api", config.baseUrl) + assertEquals("com.test.app", config.applicationId) + } + + @Test + fun `should have sensible defaults`() { + val config = BLPlatformConfig( + productId = "testapp", + baseUrl = "https://api.test.com", + applicationId = "com.test.app", + ) + assertEquals("android", config.platform) + assertEquals("native", config.channel) + assertEquals(15_000L, config.timeoutMs) + } + + @Test + fun `should allow overriding defaults`() { + val config = BLPlatformConfig( + productId = "testapp", + baseUrl = "https://api.test.com", + platform = "wear_os", + channel = "keyboard", + applicationId = "com.test.app", + timeoutMs = 5_000L, + ) + assertEquals("wear_os", config.platform) + assertEquals("keyboard", config.channel) + assertEquals(5_000L, config.timeoutMs) + } + + @Test + fun `data class equality should work`() { + val a = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") + val b = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun `copy should create modified config`() { + val original = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") + val modified = original.copy(productId = "y") + assertEquals("y", modified.productId) + assertEquals(original.baseUrl, modified.baseUrl) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt new file mode 100644 index 0000000..6d4de1a --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt @@ -0,0 +1,92 @@ +package com.bytelyst.platform + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class BLTelemetryEventTest { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @Test + fun `event should serialize to JSON`() { + val event = BLTelemetryClient.TelemetryEvent( + id = "evt-1", + productId = "testapp", + anonymousInstallId = "inst-1", + sessionId = "sess-1", + platform = "android", + channel = "native", + osFamily = "android", + osVersion = "14", + appVersion = "1.0.0", + buildNumber = "42", + releaseChannel = "beta", + eventType = "info", + module = "test", + eventName = "unit_test", + occurredAt = "2026-01-01T00:00:00Z", + ) + val jsonStr = json.encodeToString(event) + assertTrue(jsonStr.contains("\"productId\":\"testapp\"")) + assertTrue(jsonStr.contains("\"eventName\":\"unit_test\"")) + } + + @Test + fun `event should deserialize from JSON`() { + val jsonStr = """ + { + "id": "evt-1", + "productId": "testapp", + "anonymousInstallId": "inst-1", + "sessionId": "sess-1", + "platform": "android", + "channel": "native", + "osFamily": "android", + "osVersion": "14", + "appVersion": "1.0.0", + "buildNumber": "42", + "releaseChannel": "beta", + "eventType": "info", + "module": "test", + "eventName": "unit_test", + "occurredAt": "2026-01-01T00:00:00Z" + } + """.trimIndent() + val event = json.decodeFromString(jsonStr) + assertEquals("testapp", event.productId) + assertEquals("unit_test", event.eventName) + assertNull(event.feature) + assertNull(event.tags) + } + + @Test + fun `event with optional fields should serialize correctly`() { + val event = BLTelemetryClient.TelemetryEvent( + id = "evt-2", + productId = "testapp", + anonymousInstallId = "inst-1", + sessionId = "sess-1", + platform = "android", + channel = "native", + osFamily = "android", + osVersion = "14", + appVersion = "1.0.0", + buildNumber = "42", + releaseChannel = "beta", + eventType = "error", + module = "auth", + eventName = "login_failed", + feature = "social_login", + message = "Token expired", + tags = mapOf("provider" to "google"), + metrics = mapOf("retryCount" to 3.0), + occurredAt = "2026-01-01T00:00:00Z", + ) + val jsonStr = json.encodeToString(event) + assertTrue(jsonStr.contains("\"feature\":\"social_login\"")) + assertTrue(jsonStr.contains("\"provider\":\"google\"")) + assertTrue(jsonStr.contains("\"retryCount\":3.0")) + } +} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt new file mode 100644 index 0000000..5b1309b --- /dev/null +++ b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt @@ -0,0 +1,216 @@ +package com.bytelyst.platform.diagnostics + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach + +class DiagnosticsTypesTest { + + @Test + fun `test DiagnosticsSession creation`() { + val session = DiagnosticsSession( + id = "ds_test123", + productId = "test-app", + status = DiagnosticsSessionStatus.ACTIVE, + collectionLevel = DiagnosticsCollectionLevel.DEBUG, + captureLogs = true, + captureNetwork = true, + captureScreenshots = false, + screenshotOnError = true, + maxDurationMinutes = 60, + createdAt = "2026-03-03T12:00:00Z", + expiresAt = "2026-03-03T13:00:00Z" + ) + + assertEquals("ds_test123", session.id) + assertEquals(DiagnosticsSessionStatus.ACTIVE, session.status) + assertEquals(DiagnosticsCollectionLevel.DEBUG, session.collectionLevel) + assertTrue(session.captureLogs) + } + + @Test + fun `test DiagnosticsLogEntry creation`() { + val entry = DiagnosticsLogEntry( + level = DiagnosticsLogLevel.ERROR, + message = "Something went wrong", + timestamp = "2026-03-03T12:00:00Z", + module = "TestModule", + file = "Test.kt", + line = 42, + function = "testFunction", + context = mapOf("key" to "value"), + correlationId = "corr-123" + ) + + assertEquals(DiagnosticsLogLevel.ERROR, entry.level) + assertEquals("Something went wrong", entry.message) + assertEquals(42, entry.line) + } + + @Test + fun `test DiagnosticsTraceSpan creation`() { + val span = DiagnosticsTraceSpan( + spanId = "span-123", + parentId = "parent-456", + name = "test-span", + kind = DiagnosticsSpanKind.INTERNAL, + startTime = "2026-03-03T12:00:00Z", + endTime = "2026-03-03T12:00:01Z", + durationMs = 1000.0, + attributes = mapOf("key" to "value"), + status = DiagnosticsSpanStatus.OK + ) + + assertEquals("span-123", span.spanId) + assertEquals("parent-456", span.parentId) + assertEquals(DiagnosticsSpanStatus.OK, span.status) + } + + @Test + fun `test DiagnosticsBreadcrumb creation`() { + val breadcrumb = DiagnosticsBreadcrumb( + timestamp = "2026-03-03T12:00:00Z", + category = "navigation", + message = "User tapped button", + data = mapOf("buttonId" to "submit") + ) + + assertEquals("navigation", breadcrumb.category) + assertEquals("User tapped button", breadcrumb.message) + } + + @Test + fun `test DiagnosticsNetworkRequest creation`() { + val request = DiagnosticsNetworkRequest( + id = "req-123", + url = "https://api.example.com/test", + method = "POST", + requestHeaders = mapOf("Content-Type" to "application/json"), + requestBody = "{}", + status = 200, + startTime = "2026-03-03T12:00:00Z", + endTime = "2026-03-03T12:00:01Z", + durationMs = 100.0 + ) + + assertEquals("req-123", request.id) + assertEquals(200, request.status) + assertEquals(100.0, request.durationMs!!, 0.0) + } + + @Test + fun `test DiagnosticsDeviceState creation`() { + val state = DiagnosticsDeviceState( + memoryMB = 1024, + batteryLevel = 0.75f, + isCharging = true, + storageMB = 512, + networkType = "wifi", + isOnline = true, + thermalState = DiagnosticsThermalState.NOMINAL + ) + + assertEquals(1024, state.memoryMB) + assertEquals(0.75f, state.batteryLevel) + assertTrue(state.isCharging == true) + assertEquals(DiagnosticsThermalState.NOMINAL, state.thermalState) + } +} + +class BreadcrumbTrailTest { + + private lateinit var trail: BreadcrumbTrail + + @BeforeEach + fun setup() { + trail = BreadcrumbTrail(maxSize = 3) + } + + @Test + fun `test add breadcrumb`() { + trail.add(category = "test", message = "test message") + assertEquals(1, trail.size()) + } + + @Test + fun `test evict oldest when over limit`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + trail.add(category = "c", message = "3") + trail.add(category = "d", message = "4") + + assertEquals(3, trail.size()) + val all = trail.getAll() + assertEquals("b", all[0].category) // First one evicted + } + + @Test + fun `test get last n breadcrumbs`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + trail.add(category = "c", message = "3") + + val last2 = trail.getLast(2) + assertEquals(2, last2.size) + assertEquals("b", last2[0].category) + } + + @Test + fun `test get most recent`() { + trail.add(category = "a", message = "1") + trail.add(category = "b", message = "2") + + val recent = trail.getMostRecent() + assertEquals("b", recent?.category) + } + + @Test + fun `test clear`() { + trail.add(category = "a", message = "1") + trail.clear() + assertEquals(0, trail.size()) + } +} + +class DiagnosticsConfigurationTest { + + @Test + fun `test configuration creation`() { + val config = DiagnosticsConfiguration( + productId = "test-app", + anonymousInstallId = "install-123", + platform = "android", + channel = "android_app", + osFamily = "android", + appVersion = "1.0.0", + buildNumber = "100", + releaseChannel = "beta", + serverUrl = "https://api.test.com" + ) + + assertEquals("test-app", config.productId) + assertEquals("install-123", config.anonymousInstallId) + assertEquals(5000, config.pollIntervalMs) // Default + assertEquals(100, config.maxBreadcrumbs) // Default + } + + @Test + fun `test configuration with custom values`() { + val config = DiagnosticsConfiguration( + productId = "test-app", + anonymousInstallId = "install-123", + platform = "android", + channel = "android_app", + osFamily = "android", + appVersion = "1.0.0", + buildNumber = "100", + releaseChannel = "beta", + serverUrl = "https://api.test.com", + pollIntervalMs = 10000, + maxBreadcrumbs = 50 + ) + + assertEquals(10000, config.pollIntervalMs) + assertEquals(50, config.maxBreadcrumbs) + } +} diff --git a/vendor/bytelyst/llm-router/README.md b/vendor/bytelyst/llm-router/README.md new file mode 100644 index 0000000..b15c376 --- /dev/null +++ b/vendor/bytelyst/llm-router/README.md @@ -0,0 +1,134 @@ +# @bytelyst/llm-router + +Pure-code LLM router for free-tier API providers. No LLM-in-the-loop — deterministic routing with automatic fallback, health tracking, and round-robin load distribution. + +## Features + +- **4 free providers** out of the box: Groq, OpenRouter, Together AI, Cerebras +- **Prompt classification** — regex-based detection of code/math/reasoning/creative prompts +- **Smart selection** — routes to the best model for each prompt category +- **Round-robin** — distributes load across providers to maximize free-tier usage +- **Auto-fallback** — retries on 429/5xx with next-best provider +- **Health tracking** — sliding-window stats (latency, error rate, rate-limit rate) +- **Telemetry hook** — log every routing decision for analysis +- **OpenAI-compatible** — same request/response format as OpenAI chat completions +- **Zero dependencies** — pure TypeScript, uses native `fetch` + +## Quick Start + +```bash +# Set at least one API key +export GROQ_API_KEY=gsk_... +export OPENROUTER_API_KEY=sk-or-... +export TOGETHER_API_KEY=... +export CEREBRAS_API_KEY=... +``` + +```typescript +import { LlmRouter } from '@bytelyst/llm-router'; + +const router = new LlmRouter(); + +// Automatic routing — classifier picks best provider+model +const result = await router.chat({ + messages: [{ role: 'user', content: 'Write a quicksort in TypeScript' }], +}); + +console.log(result.response.choices[0].message.content); +console.log(`Served by: ${result.provider}/${result.model} in ${result.totalLatencyMs}ms`); +``` + +## Explicit Provider Routing + +```typescript +// Force a specific provider:model +const result = await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: 'groq:llama-3.3-70b-versatile', +}); +``` + +## Telemetry + +```typescript +const router = new LlmRouter({ + onTelemetry: entry => { + // entry: { event, provider, model, attempt, latencyMs, category, tokens?, error? } + console.log(`[${entry.event}] ${entry.provider}/${entry.model} — ${entry.latencyMs}ms`); + }, +}); +``` + +## Health Monitoring + +```typescript +const snapshots = router.getHealth(); +// Returns: HealthSnapshot[] with per-provider stats +// { provider, model, totalRequests, successes, rateLimits, errors, avgLatencyMs, p95LatencyMs, healthy } +``` + +## Configuration + +```typescript +const router = new LlmRouter({ + // Override default providers + providers: [...], + // Health window (default: 60s) + healthWindowMs: 120_000, + // Error rate to mark unhealthy (default: 50%) + errorThreshold: 0.4, + // Rate-limit rate to mark unhealthy (default: 30%) + rateLimitThreshold: 0.2, + // Request timeout (default: 30s) + timeoutMs: 15_000, + // Max retry attempts (default: 3) + maxRetries: 4, +}); +``` + +## Provider Selection Logic + +1. **Classify** prompt → code, math, reasoning, creative, or general +2. **Score** each available model based on category match, speed tier, context window, and model size +3. **Filter** unhealthy models (based on sliding-window error/rate-limit rates) +4. **Round-robin** across top-scoring providers to spread rate-limit load +5. **Fallback** on 429/5xx → exclude failed model, pick next best + +## Default Provider Registry + +| Provider | Models | Speed | Strengths | +| -------------- | ---------------------------------------- | ---------- | ------------------------ | +| **Groq** | Llama 3.3 70B, Llama 3.1 8B, Gemma 2 9B | ⚡ Fastest | General, reasoning, code | +| **OpenRouter** | DeepSeek R1, Llama 3.3 70B, Gemma 2 9B | Medium | Reasoning, code, math | +| **Together** | Llama 3.3 70B Turbo, DeepSeek R1 Distill | Medium | General, reasoning, code | +| **Cerebras** | Llama 3.3 70B | ⚡ Fastest | General, reasoning, code | + +## Adding Custom Providers + +Any OpenAI-compatible endpoint works: + +```typescript +import { LlmRouter, DEFAULT_PROVIDERS } from '@bytelyst/llm-router'; + +const router = new LlmRouter({ + providers: [ + ...DEFAULT_PROVIDERS, + { + name: 'my-provider', + baseUrl: 'https://my-api.example.com/v1', + apiKeyEnv: 'MY_PROVIDER_KEY', + rpmLimit: 60, + tpmLimit: 100_000, + models: [ + { + id: 'my-model', + label: 'My Model', + contextWindow: 32_000, + strengths: ['general', 'code'], + speedTier: 2, + }, + ], + }, + ], +}); +``` diff --git a/vendor/bytelyst/llm-router/package.json b/vendor/bytelyst/llm-router/package.json new file mode 100644 index 0000000..873e1f9 --- /dev/null +++ b/vendor/bytelyst/llm-router/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bytelyst/llm-router", + "version": "0.1.5", + "description": "Pure-code LLM router for free-tier API providers with round-robin, fallback, and health tracking", + "type": "module", + "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 --pool forks", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "vitest": "^3.0.0", + "typescript": "^5.7.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts b/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts new file mode 100644 index 0000000..440bfbe --- /dev/null +++ b/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { classifyPrompt } from '../classifier.js'; + +describe('classifyPrompt', () => { + it('classifies code prompts', () => { + const result = classifyPrompt([ + { role: 'user', content: 'Write a typescript function to sort an array' }, + ]); + expect(result.category).toBe('code'); + expect(result.estimatedTokens).toBeGreaterThan(0); + }); + + it('classifies code with keywords like refactor and debug', () => { + const result = classifyPrompt([ + { role: 'user', content: 'Debug this error in my React component and refactor the handler' }, + ]); + expect(result.category).toBe('code'); + }); + + it('classifies math prompts', () => { + const result = classifyPrompt([ + { role: 'user', content: 'Calculate the integral of x^2 from 0 to 5' }, + ]); + expect(result.category).toBe('math'); + }); + + it('classifies reasoning prompts', () => { + const result = classifyPrompt([ + { + role: 'user', + content: + 'Explain step by step why this approach has trade-offs and analyze the implications', + }, + ]); + expect(result.category).toBe('reasoning'); + }); + + it('classifies creative prompts', () => { + const result = classifyPrompt([ + { role: 'user', content: 'Write a short story about a robot who learns to paint' }, + ]); + expect(result.category).toBe('creative'); + }); + + it('defaults to general for ambiguous prompts', () => { + const result = classifyPrompt([{ role: 'user', content: 'Hello, how are you?' }]); + expect(result.category).toBe('general'); + }); + + it('estimates tokens roughly correctly', () => { + const text = 'a'.repeat(400); // ~100 tokens + const result = classifyPrompt([{ role: 'user', content: text }]); + expect(result.estimatedTokens).toBe(100); + }); + + it('handles multi-message conversations', () => { + const result = classifyPrompt([ + { role: 'system', content: 'You are a coding assistant' }, + { role: 'user', content: 'Fix the bug in my python function' }, + ]); + expect(result.category).toBe('code'); + }); + + it('detects code blocks in backticks', () => { + const result = classifyPrompt([ + { + role: 'user', + content: 'What is wrong with this?\n```\nconst x = 1;\nconsole.log(x);\n```', + }, + ]); + expect(result.category).toBe('code'); + }); +}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/health.test.ts b/vendor/bytelyst/llm-router/src/__tests__/health.test.ts new file mode 100644 index 0000000..31f864d --- /dev/null +++ b/vendor/bytelyst/llm-router/src/__tests__/health.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { HealthTracker } from '../health.js'; + +describe('HealthTracker', () => { + let tracker: HealthTracker; + + beforeEach(() => { + tracker = new HealthTracker({ windowMs: 10_000, errorThreshold: 0.5, rateLimitThreshold: 0.3 }); + }); + + it('reports healthy with no data', () => { + expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); + }); + + it('reports healthy with all successes', () => { + for (let i = 0; i < 5; i++) { + tracker.record('groq', 'llama-3.3-70b', { + timestamp: Date.now(), + latencyMs: 200, + status: 'success', + }); + } + expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); + }); + + it('marks unhealthy when error rate exceeds threshold', () => { + for (let i = 0; i < 5; i++) { + tracker.record('groq', 'llama-3.3-70b', { + timestamp: Date.now(), + latencyMs: 200, + status: 'error', + }); + } + expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(false); + }); + + it('marks unhealthy when rate-limit rate exceeds threshold', () => { + // 2 successes + 3 rate limits = 60% rate limit rate > 30% threshold + for (let i = 0; i < 2; i++) { + tracker.record('openrouter', 'model-a', { + timestamp: Date.now(), + latencyMs: 100, + status: 'success', + }); + } + for (let i = 0; i < 3; i++) { + tracker.record('openrouter', 'model-a', { + timestamp: Date.now(), + latencyMs: 50, + status: 'rate_limit', + }); + } + expect(tracker.isHealthy('openrouter', 'model-a')).toBe(false); + }); + + it('assumes healthy with fewer than 3 records', () => { + tracker.record('groq', 'llama-3.3-70b', { + timestamp: Date.now(), + latencyMs: 200, + status: 'error', + }); + tracker.record('groq', 'llama-3.3-70b', { + timestamp: Date.now(), + latencyMs: 200, + status: 'error', + }); + // Only 2 records — not enough data, should still be healthy + expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); + }); + + it('computes avg and p95 latency', () => { + const latencies = [100, 200, 300, 400, 500]; + for (const latencyMs of latencies) { + tracker.record('groq', 'model-a', { + timestamp: Date.now(), + latencyMs, + status: 'success', + }); + } + const snap = tracker.snapshot('groq', 'model-a'); + expect(snap.avgLatencyMs).toBe(300); + expect(snap.p95LatencyMs).toBe(500); + expect(snap.successes).toBe(5); + }); + + it('tracks different providers independently', () => { + tracker.record('groq', 'model-a', { + timestamp: Date.now(), + latencyMs: 100, + status: 'success', + }); + tracker.record('openrouter', 'model-b', { + timestamp: Date.now(), + latencyMs: 500, + status: 'error', + }); + + const snapA = tracker.snapshot('groq', 'model-a'); + const snapB = tracker.snapshot('openrouter', 'model-b'); + expect(snapA.successes).toBe(1); + expect(snapB.errors).toBe(1); + }); + + it('returns all snapshots', () => { + tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); + tracker.record('together', 'model-b', { + timestamp: Date.now(), + latencyMs: 200, + status: 'success', + }); + + const all = tracker.allSnapshots(); + expect(all).toHaveLength(2); + }); + + it('resets all data', () => { + tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); + tracker.reset(); + expect(tracker.allSnapshots()).toHaveLength(0); + }); +}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts b/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts new file mode 100644 index 0000000..793ab86 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + createLocalOllamaProvider, + getAvailableProviders, + DEFAULT_PROVIDERS, +} from '../registry.js'; +import type { ProviderConfig } from '../types.js'; + +describe('getAvailableProviders', () => { + const saved: Record = {}; + + beforeEach(() => { + // Save and clear all default provider env vars + for (const p of DEFAULT_PROVIDERS) { + const key = p.apiKeyEnv!; + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + // Restore original env + for (const [key, val] of Object.entries(saved)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + }); + + it('returns empty array when no API keys are set', () => { + expect(getAvailableProviders()).toEqual([]); + }); + + it('returns only providers with API keys set', () => { + process.env.GROQ_API_KEY = 'gsk_test'; + const result = getAvailableProviders(); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('groq'); + }); + + it('returns multiple providers when multiple keys are set', () => { + process.env.GROQ_API_KEY = 'gsk_test'; + process.env.CEREBRAS_API_KEY = 'csk_test'; + const result = getAvailableProviders(); + expect(result).toHaveLength(2); + const names = result.map(p => p.name); + expect(names).toContain('groq'); + expect(names).toContain('cerebras'); + }); + + it('excludes providers with empty string API key', () => { + process.env.GROQ_API_KEY = ''; + expect(getAvailableProviders()).toEqual([]); + }); + + it('works with custom provider list', () => { + const custom: ProviderConfig[] = [ + { + name: 'custom', + baseUrl: 'https://example.com/v1', + apiKeyEnv: 'CUSTOM_TEST_KEY', + rpmLimit: 10, + tpmLimit: 0, + models: [], + }, + ]; + expect(getAvailableProviders(custom)).toEqual([]); + + process.env.CUSTOM_TEST_KEY = 'test'; + expect(getAvailableProviders(custom)).toHaveLength(1); + delete process.env.CUSTOM_TEST_KEY; + }); + + it('includes local providers that do not require API keys', () => { + const local = createLocalOllamaProvider(['qwen2.5-coder:7b']); + const result = getAvailableProviders([local]); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('local-ollama'); + }); + + it('infers local ollama model metadata for routing', () => { + const local = createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:70b']); + + expect(local.baseUrl).toBe('http://localhost:11434/v1'); + expect(local.models).toEqual([ + expect.objectContaining({ + id: 'qwen2.5-coder:7b', + contextWindow: 32_768, + speedTier: 1, + strengths: expect.arrayContaining(['code']), + }), + expect.objectContaining({ + id: 'llama3.1:70b', + contextWindow: 8_192, + speedTier: 3, + strengths: expect.arrayContaining(['general']), + }), + ]); + }); + + it('DEFAULT_PROVIDERS includes all 4 providers', () => { + expect(DEFAULT_PROVIDERS).toHaveLength(4); + const names = DEFAULT_PROVIDERS.map(p => p.name); + expect(names).toEqual(['groq', 'openrouter', 'together', 'cerebras']); + }); + + it('OpenRouter provider has recommended extra headers', () => { + const openrouter = DEFAULT_PROVIDERS.find(p => p.name === 'openrouter'); + expect(openrouter?.extraHeaders).toBeDefined(); + expect(openrouter?.extraHeaders?.['HTTP-Referer']).toBeDefined(); + expect(openrouter?.extraHeaders?.['X-Title']).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/router.test.ts b/vendor/bytelyst/llm-router/src/__tests__/router.test.ts new file mode 100644 index 0000000..cf90082 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/__tests__/router.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { LlmRouter } from '../router.js'; +import { createLocalOllamaProvider } from '../registry.js'; +import type { ProviderConfig, ChatCompletionResponse } from '../types.js'; +import * as client from '../client.js'; + +// Mock the HTTP client +vi.mock('../client.js', () => ({ + sendChatCompletion: vi.fn(), +})); + +const MOCK_RESPONSE: ChatCompletionResponse = { + id: 'chatcmpl-test', + object: 'chat.completion', + created: Date.now(), + model: 'test-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, +}; + +const TEST_PROVIDERS: ProviderConfig[] = [ + { + name: 'test-fast', + baseUrl: 'https://fast.test/v1', + apiKeyEnv: 'TEST_FAST_KEY', + rpmLimit: 30, + tpmLimit: 10_000, + models: [ + { + id: 'fast-model', + label: 'Fast', + contextWindow: 8_192, + strengths: ['general'], + speedTier: 1, + }, + ], + }, + { + name: 'test-quality', + baseUrl: 'https://quality.test/v1', + apiKeyEnv: 'TEST_QUALITY_KEY', + rpmLimit: 20, + tpmLimit: 0, + models: [ + { + id: 'quality-model', + label: 'Quality', + contextWindow: 128_000, + strengths: ['code', 'reasoning'], + speedTier: 2, + }, + ], + }, +]; + +describe('LlmRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + // Set fake API keys + process.env.TEST_FAST_KEY = 'test-key-fast'; + process.env.TEST_QUALITY_KEY = 'test-key-quality'; + }); + + afterEach(() => { + delete process.env.TEST_FAST_KEY; + delete process.env.TEST_QUALITY_KEY; + }); + + it('throws if no providers have API keys', () => { + delete process.env.TEST_FAST_KEY; + delete process.env.TEST_QUALITY_KEY; + expect(() => new LlmRouter({ providers: TEST_PROVIDERS })).toThrow('No providers available'); + }); + + it('routes a simple prompt to a provider', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 150, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + const result = await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(result.response.choices[0]!.message.content).toBe('Hello!'); + expect(result.attempts).toBe(1); + expect(result.provider).toBe('test-fast'); + expect(result.model).toBe('fast-model'); + }); + + it('retries on 429 with fallback provider', async () => { + // First call: rate limited + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: null as unknown as ChatCompletionResponse, + latencyMs: 50, + status: 429, + }); + // Second call: success + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 200, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + const result = await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(result.attempts).toBe(2); + expect(result.response.choices[0]!.message.content).toBe('Hello!'); + }); + + it('retries on error with fallback provider', async () => { + // First call: error + vi.mocked(client.sendChatCompletion).mockRejectedValueOnce(new Error('Network error')); + // Second call: success + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 200, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + const result = await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(result.attempts).toBe(2); + }); + + it('throws after exhausting all retries', async () => { + vi.mocked(client.sendChatCompletion).mockRejectedValue(new Error('All down')); + + const router = new LlmRouter({ providers: TEST_PROVIDERS, maxRetries: 2 }); + await expect(router.chat({ messages: [{ role: 'user', content: 'Hello' }] })).rejects.toThrow( + 'All providers exhausted' + ); + }); + + it('routes code prompts to code-capable models', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 200, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + await router.chat({ + messages: [{ role: 'user', content: 'Write a typescript function to sort an array' }], + }); + + // Should have been called with quality-model (has 'code' strength) + const callArgs = vi.mocked(client.sendChatCompletion).mock.calls[0]!; + expect(callArgs[1]).toBe('quality-model'); + }); + + it('fires telemetry callback on success', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 150, + status: 200, + }); + + const telemetry = vi.fn(); + const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); + await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); + + expect(telemetry).toHaveBeenCalledWith( + expect.objectContaining({ event: 'success', attempt: 1 }) + ); + }); + + it('fires telemetry callback on rate limit', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: null as unknown as ChatCompletionResponse, + latencyMs: 50, + status: 429, + }); + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 200, + status: 200, + }); + + const telemetry = vi.fn(); + const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); + await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); + + expect(telemetry).toHaveBeenCalledWith(expect.objectContaining({ event: 'rate_limit' })); + }); + + it('handles explicit provider:model routing', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 100, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + const result = await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: 'test-fast:fast-model', + }); + + expect(result.provider).toBe('test-fast'); + expect(result.model).toBe('fast-model'); + }); + + it('throws for unknown explicit provider', async () => { + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + await expect( + router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'unknown:model' }) + ).rejects.toThrow('Provider "unknown" not found'); + }); + + it('returns health snapshots', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 150, + status: 200, + }); + + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); + + const health = router.getHealth(); + expect(health).toContainEqual( + expect.objectContaining({ + provider: 'test-fast', + model: 'fast-model', + successes: 1, + }) + ); + }); + + it('lists available providers', () => { + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + expect(router.getProviders()).toEqual(['test-fast', 'test-quality']); + }); + + it('plans the best provider/model without executing a request', () => { + const router = new LlmRouter({ providers: TEST_PROVIDERS }); + const plan = router.plan({ + messages: [{ role: 'user', content: 'Write a TypeScript endpoint with validation' }], + }); + + expect(plan.provider.name).toBe('test-quality'); + expect(plan.model.id).toBe('quality-model'); + expect(plan.category).toBe('code'); + expect(plan.explicit).toBe(false); + }); + + it('plans local ollama models without requiring an API key', () => { + const router = new LlmRouter({ + providers: [createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:8b'])], + }); + const plan = router.plan({ + messages: [{ role: 'user', content: 'Refactor this TypeScript function' }], + }); + + expect(plan.provider.name).toBe('local-ollama'); + expect(plan.model.id).toBe('qwen2.5-coder:7b'); + }); + + it('fires telemetry for explicit model routing', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: MOCK_RESPONSE, + latencyMs: 100, + status: 200, + }); + + const telemetry = vi.fn(); + const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); + await router.chat({ + messages: [{ role: 'user', content: 'Hello' }], + model: 'test-fast:fast-model', + }); + + expect(telemetry).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'success', + provider: 'test-fast', + model: 'fast-model', + category: 'explicit', + }) + ); + }); + + it('records health on explicit model 429', async () => { + vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ + response: null as unknown as ChatCompletionResponse, + latencyMs: 50, + status: 429, + }); + + const telemetry = vi.fn(); + const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); + await expect( + router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'test-fast:fast-model' }) + ).rejects.toThrow('Rate limited'); + + expect(telemetry).toHaveBeenCalledWith( + expect.objectContaining({ event: 'rate_limit', provider: 'test-fast' }) + ); + + // Health should have recorded the rate limit + const health = router.getHealth(); + expect(health).toHaveLength(1); + expect(health[0]!.rateLimits).toBe(1); + }); +}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts b/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts new file mode 100644 index 0000000..429d629 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + selectCandidates, + pickNext, + excludeCandidate, + createRoundRobinState, +} from '../selector.js'; +import { HealthTracker } from '../health.js'; +import type { ProviderConfig } from '../types.js'; + +const MOCK_PROVIDERS: ProviderConfig[] = [ + { + name: 'fast-provider', + baseUrl: 'https://fast.example.com/v1', + apiKeyEnv: 'FAST_KEY', + rpmLimit: 30, + tpmLimit: 10_000, + models: [ + { + id: 'small-8b', + label: 'Small 8B', + contextWindow: 8_192, + strengths: ['general'], + speedTier: 1, + }, + { + id: 'large-70b', + label: 'Large 70B', + contextWindow: 128_000, + strengths: ['code', 'reasoning'], + speedTier: 1, + }, + ], + }, + { + name: 'quality-provider', + baseUrl: 'https://quality.example.com/v1', + apiKeyEnv: 'QUALITY_KEY', + rpmLimit: 20, + tpmLimit: 0, + models: [ + { + id: 'deepseek-r1', + label: 'DeepSeek R1', + contextWindow: 64_000, + strengths: ['reasoning', 'code', 'math'], + speedTier: 3, + }, + ], + }, +]; + +describe('selectCandidates', () => { + let health: HealthTracker; + + beforeEach(() => { + health = new HealthTracker(); + }); + + it('returns candidates sorted by score for code', () => { + const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); + expect(candidates.length).toBeGreaterThan(0); + // large-70b and deepseek-r1 should score high for code + const names = candidates.map(c => c.model.id); + expect(names[0]).toBe('large-70b'); // speed 1 + code strength + 70b bonus + }); + + it('returns candidates sorted by score for general', () => { + const candidates = selectCandidates(MOCK_PROVIDERS, 'general', health); + // small-8b has 'general' strength + speed tier 1 + expect(candidates[0]!.model.id).toBe('small-8b'); + }); + + it('filters out unhealthy providers', () => { + // Make fast-provider/large-70b unhealthy + for (let i = 0; i < 5; i++) { + health.record('fast-provider', 'large-70b', { + timestamp: Date.now(), + latencyMs: 100, + status: 'error', + }); + } + const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); + const ids = candidates.map(c => `${c.provider.name}::${c.model.id}`); + expect(ids).not.toContain('fast-provider::large-70b'); + }); +}); + +describe('pickNext', () => { + it('returns null for empty candidates', () => { + const state = createRoundRobinState(); + expect(pickNext([], state)).toBeNull(); + }); + + it('returns the only candidate when there is one', () => { + const state = createRoundRobinState(); + const candidate = { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }; + expect(pickNext([candidate], state)).toBe(candidate); + }); + + it('round-robins across providers', () => { + const state = createRoundRobinState(); + const candidates = [ + { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, + { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, + ]; + + const first = pickNext(candidates, state); + const second = pickNext(candidates, state); + expect(first!.provider.name).not.toBe(second!.provider.name); + }); + + it('uses independent state per instance', () => { + const stateA = createRoundRobinState(); + const stateB = createRoundRobinState(); + const candidates = [ + { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, + { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, + ]; + + const fromA = pickNext(candidates, stateA); + const fromB = pickNext(candidates, stateB); + // Both start at same position since states are independent + expect(fromA!.provider.name).toBe(fromB!.provider.name); + }); +}); + +describe('excludeCandidate', () => { + it('removes the specified candidate', () => { + const candidates = [ + { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, + { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, + ]; + const remaining = excludeCandidate(candidates, 'fast-provider', 'small-8b'); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.provider.name).toBe('quality-provider'); + }); +}); diff --git a/vendor/bytelyst/llm-router/src/classifier.ts b/vendor/bytelyst/llm-router/src/classifier.ts new file mode 100644 index 0000000..b8b5c95 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/classifier.ts @@ -0,0 +1,115 @@ +import type { ClassificationResult, PromptCategory } from './types.js'; + +// ── Keyword patterns for classification ──────────────────────── + +const CODE_PATTERNS = [ + /\b(function|const |let |var |class |import |export |return |async |await )\b/, + /\b(def |print\(|if __name__|lambda )\b/, + /[{}();]=>/, + /```[\s\S]*```/, + /\b(typescript|javascript|python|rust|golang|java|kotlin|swift|sql|html|css|react|node)\b/i, + /\b(debug|refactor|compile|build|deploy|lint|test|api|endpoint|route|middleware)\b/i, + /\b(fix|bug|error|exception|stack trace|undefined|null|NaN)\b/i, +]; + +const MATH_PATTERNS = [ + /\b(calculate|compute|solve|equation|formula|integral|derivative|matrix)\b/i, + /\b(probability|statistics|regression|correlation|variance|median|mean)\b/i, + /\b(algebra|geometry|calculus|theorem|proof|hypothesis)\b/i, + /[+\-*/^=]{2,}/, + /\d+\s*[+\-*/^]\s*\d+/, +]; + +const REASONING_PATTERNS = [ + /\b(explain|analyze|compare|evaluate|reason|logic|argument|conclusion)\b/i, + /\b(why|how does|what if|pros and cons|trade-?offs|implications)\b/i, + /\b(step[- ]by[- ]step|chain of thought|think through|break down)\b/i, + /\b(strategy|approach|methodology|framework|architecture|design)\b/i, +]; + +const CREATIVE_PATTERNS = [ + /\b(write|compose|draft|create|generate|story|poem|essay|blog|article)\b/i, + /\b(creative|imaginative|brainstorm|ideas|fiction|narrative|dialogue)\b/i, + /\b(rewrite|rephrase|summarize|translate|tone|style|voice)\b/i, +]; + +// ── Token estimation ─────────────────────────────────────────── + +/** + * Rough token estimate: ~4 chars per token for English text. + * Good enough for routing decisions. + */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +// ── Classifier ───────────────────────────────────────────────── + +function countMatches(text: string, patterns: RegExp[]): number { + let count = 0; + for (const pattern of patterns) { + if (pattern.test(text)) count++; + } + return count; +} + +/** + * Check if messages contain image content parts (vision request). + * Handles both string content and multipart content arrays. + */ +function hasImageContent(messages: { role: string; content: string | unknown[] }[]): boolean { + for (const msg of messages) { + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if ( + typeof part === 'object' && + part !== null && + 'type' in part && + (part as { type: string }).type === 'image_url' + ) { + return true; + } + } + } + } + return false; +} + +/** + * Classify a prompt into a category based on keyword matching. + * No LLM needed — pure regex heuristics. + * Detects vision (image) content and returns 'vision' category when present. + */ +export function classifyPrompt( + messages: { role: string; content: string | unknown[] }[] +): ClassificationResult { + // Check for vision content first — image inputs always classify as 'vision' + if (hasImageContent(messages)) { + const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); + return { category: 'vision', estimatedTokens: estimateTokens(fullText) + 1000 }; + } + + const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); + const estimatedTokens = estimateTokens(fullText); + + const scores: Record = { + code: countMatches(fullText, CODE_PATTERNS), + math: countMatches(fullText, MATH_PATTERNS), + reasoning: countMatches(fullText, REASONING_PATTERNS), + creative: countMatches(fullText, CREATIVE_PATTERNS), + general: 1, // baseline + vision: 0, + }; + + // Pick highest scoring category + let best: PromptCategory = 'general'; + let bestScore = 0; + for (const [cat, score] of Object.entries(scores) as [PromptCategory, number][]) { + if (score > bestScore) { + bestScore = score; + best = cat; + } + } + + return { category: best, estimatedTokens }; +} diff --git a/vendor/bytelyst/llm-router/src/client.ts b/vendor/bytelyst/llm-router/src/client.ts new file mode 100644 index 0000000..fe57769 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/client.ts @@ -0,0 +1,68 @@ +import type { ChatCompletionRequest, ChatCompletionResponse, ProviderConfig } from './types.js'; + +/** + * Send an OpenAI-compatible chat completion request to a provider. + * Returns the parsed response or throws on HTTP/network errors. + */ +export async function sendChatCompletion( + provider: ProviderConfig, + modelId: string, + request: ChatCompletionRequest, + timeoutMs: number = 30_000 +): Promise<{ response: ChatCompletionResponse; latencyMs: number; status: number }> { + const apiKey = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : null; + if (provider.apiKeyEnv && !apiKey) { + throw new Error(`Missing API key: env var ${provider.apiKeyEnv} is not set`); + } + + const url = `${provider.baseUrl}/chat/completions`; + const headers: Record = { + 'Content-Type': 'application/json', + ...provider.extraHeaders, + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const body = JSON.stringify({ + model: modelId, + messages: request.messages, + ...(request.temperature !== undefined && { temperature: request.temperature }), + ...(request.max_tokens !== undefined && { max_tokens: request.max_tokens }), + ...(request.top_p !== undefined && { top_p: request.top_p }), + stream: false, + }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const start = Date.now(); + + try { + const res = await fetch(url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + + const latencyMs = Date.now() - start; + + if (res.status === 429) { + return { + response: null as unknown as ChatCompletionResponse, + latencyMs, + status: 429, + }; + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`${provider.name} returned ${res.status}: ${text.slice(0, 200)}`); + } + + const data = (await res.json()) as ChatCompletionResponse; + return { response: data, latencyMs, status: res.status }; + } finally { + clearTimeout(timer); + } +} diff --git a/vendor/bytelyst/llm-router/src/health.ts b/vendor/bytelyst/llm-router/src/health.ts new file mode 100644 index 0000000..7e1fc9c --- /dev/null +++ b/vendor/bytelyst/llm-router/src/health.ts @@ -0,0 +1,103 @@ +import type { HealthSnapshot, RequestRecord } from './types.js'; + +/** + * Sliding-window health tracker for provider+model pairs. + * Tracks latency, error rates, and rate-limit hits. + */ +export class HealthTracker { + private records = new Map(); + private readonly windowMs: number; + private readonly errorThreshold: number; + private readonly rateLimitThreshold: number; + + constructor(opts?: { windowMs?: number; errorThreshold?: number; rateLimitThreshold?: number }) { + this.windowMs = opts?.windowMs ?? 60_000; + this.errorThreshold = opts?.errorThreshold ?? 0.5; + this.rateLimitThreshold = opts?.rateLimitThreshold ?? 0.3; + } + + private key(provider: string, model: string): string { + return `${provider}::${model}`; + } + + private prune(records: RequestRecord[]): RequestRecord[] { + const cutoff = Date.now() - this.windowMs; + return records.filter(r => r.timestamp >= cutoff); + } + + /** Record a completed request (success, rate_limit, or error). */ + record(provider: string, model: string, entry: RequestRecord): void { + const k = this.key(provider, model); + const existing = this.records.get(k) ?? []; + existing.push(entry); + this.records.set(k, this.prune(existing)); + } + + /** Get health snapshot for a provider+model pair. */ + snapshot(provider: string, model: string): HealthSnapshot { + const k = this.key(provider, model); + const raw = this.records.get(k) ?? []; + const records = this.prune(raw); + this.records.set(k, records); + + const total = records.length; + const successes = records.filter(r => r.status === 'success').length; + const rateLimits = records.filter(r => r.status === 'rate_limit').length; + const errors = records.filter(r => r.status === 'error').length; + + const successLatencies = records + .filter(r => r.status === 'success') + .map(r => r.latencyMs) + .sort((a, b) => a - b); + + const avgLatencyMs = + successLatencies.length > 0 + ? successLatencies.reduce((a, b) => a + b, 0) / successLatencies.length + : 0; + + const p95LatencyMs = + successLatencies.length > 0 + ? (successLatencies[Math.floor(successLatencies.length * 0.95)] ?? + successLatencies[successLatencies.length - 1]!) + : 0; + + // Healthy = not too many errors or rate limits + const errorRate = total > 0 ? errors / total : 0; + const rateLimitRate = total > 0 ? rateLimits / total : 0; + const healthy = + total < 3 || // not enough data → assume healthy + (errorRate < this.errorThreshold && rateLimitRate < this.rateLimitThreshold); + + return { + provider, + model, + totalRequests: total, + successes, + rateLimits, + errors, + avgLatencyMs: Math.round(avgLatencyMs), + p95LatencyMs: Math.round(p95LatencyMs), + healthy, + }; + } + + /** Check if a specific provider+model is currently healthy. */ + isHealthy(provider: string, model: string): boolean { + return this.snapshot(provider, model).healthy; + } + + /** Get all tracked snapshots. */ + allSnapshots(): HealthSnapshot[] { + const snapshots: HealthSnapshot[] = []; + for (const k of this.records.keys()) { + const [provider, model] = k.split('::') as [string, string]; + snapshots.push(this.snapshot(provider, model)); + } + return snapshots; + } + + /** Clear all tracking data. */ + reset(): void { + this.records.clear(); + } +} diff --git a/vendor/bytelyst/llm-router/src/index.ts b/vendor/bytelyst/llm-router/src/index.ts new file mode 100644 index 0000000..7660254 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/index.ts @@ -0,0 +1,31 @@ +export { LlmRouter } from './router.js'; +export type { TelemetryEntry } from './router.js'; + +export { + DEFAULT_PROVIDERS, + PAID_PROVIDERS, + createLocalOllamaProvider, + getAvailableProviders, +} from './registry.js'; +export { classifyPrompt } from './classifier.js'; +export { HealthTracker } from './health.js'; +export { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; +export type { SelectionCandidate } from './selector.js'; +export { sendChatCompletion } from './client.js'; + +export type { + ModelConfig, + ProviderConfig, + PromptCategory, + ClassificationResult, + HealthSnapshot, + RequestRecord, + RouterConfig, + ChatMessage, + ChatCompletionRequest, + ChatCompletionChoice, + ChatCompletionUsage, + ChatCompletionResponse, + RouteResult, + RoutePlan, +} from './types.js'; diff --git a/vendor/bytelyst/llm-router/src/registry.ts b/vendor/bytelyst/llm-router/src/registry.ts new file mode 100644 index 0000000..5c2c7d6 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/registry.ts @@ -0,0 +1,244 @@ +import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; + +/** + * Paid provider configurations (opt-in via API key env vars). + * Add to your RouterConfig.providers to include alongside free-tier providers. + */ +export const PAID_PROVIDERS: ProviderConfig[] = [ + // ── OpenAI ─────────────────────────────────────────────────── + { + name: 'openai', + baseUrl: 'https://api.openai.com/v1', + apiKeyEnv: 'OPENAI_API_KEY', + rpmLimit: 500, + tpmLimit: 150_000, + models: [ + { + id: 'gpt-4o-mini', + label: 'GPT-4o Mini', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code'], + speedTier: 1, + }, + { + id: 'gpt-4o', + label: 'GPT-4o', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code', 'creative', 'vision'], + speedTier: 2, + supportsVision: true, + }, + ], + }, + + // ── Perplexity ─────────────────────────────────────────────── + // Real-time web search grounding — OpenAI-compatible endpoint + { + name: 'perplexity', + baseUrl: 'https://api.perplexity.ai', + apiKeyEnv: 'PERPLEXITY_API_KEY', + rpmLimit: 50, + tpmLimit: 0, + models: [ + { + id: 'sonar', + label: 'Sonar (web search)', + contextWindow: 127_072, + strengths: ['general', 'reasoning'], + speedTier: 2, + }, + { + id: 'sonar-pro', + label: 'Sonar Pro (web search)', + contextWindow: 200_000, + strengths: ['general', 'reasoning'], + speedTier: 3, + }, + ], + }, +]; + +/** + * Default free-tier provider configurations. + * All use OpenAI-compatible /v1/chat/completions endpoints. + */ +export const DEFAULT_PROVIDERS: ProviderConfig[] = [ + // ── Groq ───────────────────────────────────────────────────── + // Free tier: 30 RPM, 14.4K TPM (large), 30K TPM (small) + { + name: 'groq', + baseUrl: 'https://api.groq.com/openai/v1', + apiKeyEnv: 'GROQ_API_KEY', + rpmLimit: 30, + tpmLimit: 14_400, + models: [ + { + id: 'llama-3.3-70b-versatile', + label: 'Llama 3.3 70B', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code'], + speedTier: 1, + }, + { + id: 'llama-3.1-8b-instant', + label: 'Llama 3.1 8B Instant', + contextWindow: 128_000, + strengths: ['general'], + speedTier: 1, + }, + { + id: 'gemma2-9b-it', + label: 'Gemma 2 9B', + contextWindow: 8_192, + strengths: ['general', 'creative'], + speedTier: 1, + }, + ], + }, + + // ── OpenRouter ─────────────────────────────────────────────── + // Free models available (rate-limited per model) + { + name: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + apiKeyEnv: 'OPENROUTER_API_KEY', + extraHeaders: { + 'HTTP-Referer': 'https://bytelyst.com', + 'X-Title': 'ByteLyst LLM Router', + }, + rpmLimit: 20, + tpmLimit: 0, + models: [ + { + id: 'deepseek/deepseek-r1:free', + label: 'DeepSeek R1 (Free)', + contextWindow: 64_000, + strengths: ['reasoning', 'code', 'math'], + speedTier: 3, + }, + { + id: 'meta-llama/llama-3.3-70b-instruct:free', + label: 'Llama 3.3 70B (Free)', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code'], + speedTier: 2, + }, + { + id: 'google/gemma-2-9b-it:free', + label: 'Gemma 2 9B (Free)', + contextWindow: 8_192, + strengths: ['general', 'creative'], + speedTier: 2, + }, + ], + }, + + // ── Together AI ────────────────────────────────────────────── + // Free tier: limited RPM, several open models + { + name: 'together', + baseUrl: 'https://api.together.xyz/v1', + apiKeyEnv: 'TOGETHER_API_KEY', + rpmLimit: 20, + tpmLimit: 0, + models: [ + { + id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + label: 'Llama 3.3 70B Turbo', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code'], + speedTier: 2, + }, + { + id: 'deepseek-ai/DeepSeek-R1-Distill-Llama-70B', + label: 'DeepSeek R1 Distill 70B', + contextWindow: 128_000, + strengths: ['reasoning', 'math', 'code'], + speedTier: 2, + }, + ], + }, + + // ── Cerebras ───────────────────────────────────────────────── + // Free inference tier — extremely fast + { + name: 'cerebras', + baseUrl: 'https://api.cerebras.ai/v1', + apiKeyEnv: 'CEREBRAS_API_KEY', + rpmLimit: 30, + tpmLimit: 60_000, + models: [ + { + id: 'llama-3.3-70b', + label: 'Llama 3.3 70B (Cerebras)', + contextWindow: 128_000, + strengths: ['general', 'reasoning', 'code'], + speedTier: 1, + }, + ], + }, +]; + +function inferStrengths(modelId: string): PromptCategory[] { + const lower = modelId.toLowerCase(); + const strengths = new Set(['general']); + + if (/coder|code|codestral|starcoder|deepseek/.test(lower)) strengths.add('code'); + if (/r1|reason|think|math/.test(lower)) { + strengths.add('reasoning'); + strengths.add('math'); + } + if (/qwen|llama|mistral|chat/.test(lower)) strengths.add('creative'); + + return [...strengths]; +} + +function inferContextWindow(modelId: string): number { + const lower = modelId.toLowerCase(); + if (/128k|131072/.test(lower)) return 128_000; + if (/64k|65536/.test(lower)) return 64_000; + if (/32k|32768|qwen2\.5/.test(lower)) return 32_768; + if (/16k|16384/.test(lower)) return 16_384; + return 8_192; +} + +function inferSpeedTier(modelId: string): 1 | 2 | 3 { + const lower = modelId.toLowerCase(); + if (/0\.5b|1b|3b|7b|mini|tiny/.test(lower)) return 1; + if (/14b|15b|16b|20b|22b|30b|32b/.test(lower)) return 2; + return 3; +} + +export function createLocalOllamaProvider( + modelIds: string[], + baseUrl: string = 'http://localhost:11434/v1' +): ProviderConfig { + const models: ModelConfig[] = modelIds.map(modelId => ({ + id: modelId, + label: modelId, + contextWindow: inferContextWindow(modelId), + strengths: inferStrengths(modelId), + speedTier: inferSpeedTier(modelId), + })); + + return { + name: 'local-ollama', + baseUrl, + models, + rpmLimit: 0, + tpmLimit: 0, + }; +} + +/** + * Filter providers to only those with API keys present in env. + */ +export function getAvailableProviders( + providers: ProviderConfig[] = DEFAULT_PROVIDERS +): ProviderConfig[] { + return providers.filter(p => { + if (!p.apiKeyEnv) return true; + const key = process.env[p.apiKeyEnv]; + return key !== undefined && key !== ''; + }); +} diff --git a/vendor/bytelyst/llm-router/src/router.ts b/vendor/bytelyst/llm-router/src/router.ts new file mode 100644 index 0000000..f46951d --- /dev/null +++ b/vendor/bytelyst/llm-router/src/router.ts @@ -0,0 +1,362 @@ +import type { + ChatCompletionRequest, + PromptCategory, + RouterConfig, + ProviderConfig, + RouteResult, + RoutePlan, + HealthSnapshot, +} from './types.js'; +import { DEFAULT_PROVIDERS, getAvailableProviders } from './registry.js'; +import { classifyPrompt } from './classifier.js'; +import { HealthTracker } from './health.js'; +import { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; +import { sendChatCompletion } from './client.js'; + +export class LlmRouter { + private readonly providers: ProviderConfig[]; + private readonly health: HealthTracker; + private readonly timeoutMs: number; + private readonly maxRetries: number; + private readonly log: (entry: TelemetryEntry) => void; + private readonly roundRobinState: Map; + + constructor(config?: RouterConfig & { onTelemetry?: (entry: TelemetryEntry) => void }) { + const allProviders = config?.providers ?? DEFAULT_PROVIDERS; + this.providers = getAvailableProviders(allProviders); + + if (this.providers.length === 0) { + throw new Error( + 'No providers available. Set at least one API key env var: ' + + allProviders.map(p => p.apiKeyEnv).join(', ') + ); + } + + this.health = new HealthTracker({ + windowMs: config?.healthWindowMs, + errorThreshold: config?.errorThreshold, + rateLimitThreshold: config?.rateLimitThreshold, + }); + + this.timeoutMs = config?.timeoutMs ?? 30_000; + this.maxRetries = config?.maxRetries ?? 3; + this.log = config?.onTelemetry ?? (() => {}); + this.roundRobinState = createRoundRobinState(); + } + + /** + * Route a chat completion request to the best available provider. + * Automatically retries on 429/5xx with fallback to other providers. + */ + async chat(request: ChatCompletionRequest): Promise { + const startTime = Date.now(); + + const plan = this.planInternal(request, false); + + if (plan.explicit) { + return this.chatWithExplicitModel(request, startTime, plan); + } + + const category = plan.category as PromptCategory; + let candidates = selectCandidates(this.providers, category, this.health); + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + const pick = pickNext(candidates, this.roundRobinState); + if (!pick) break; + + const { provider, model } = pick; + const attemptStart = Date.now(); + + try { + const result = await sendChatCompletion(provider, model.id, request, this.timeoutMs); + + if (result.status === 429) { + // Rate limited — record and try next provider + this.health.record(provider.name, model.id, { + timestamp: Date.now(), + latencyMs: result.latencyMs, + status: 'rate_limit', + }); + + this.log({ + event: 'rate_limit', + provider: provider.name, + model: model.id, + attempt, + latencyMs: result.latencyMs, + category, + }); + + candidates = excludeCandidate(candidates, provider.name, model.id); + continue; + } + + // Success + this.health.record(provider.name, model.id, { + timestamp: Date.now(), + latencyMs: result.latencyMs, + status: 'success', + }); + + this.log({ + event: 'success', + provider: provider.name, + model: model.id, + attempt, + latencyMs: result.latencyMs, + category, + tokens: result.response.usage?.total_tokens, + }); + + return { + response: result.response, + provider: provider.name, + model: model.id, + totalLatencyMs: Date.now() - startTime, + attempts: attempt, + }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + const attemptLatency = Date.now() - attemptStart; + + this.health.record(provider.name, model.id, { + timestamp: Date.now(), + latencyMs: attemptLatency, + status: 'error', + }); + + this.log({ + event: 'error', + provider: provider.name, + model: model.id, + attempt, + latencyMs: attemptLatency, + category, + error: lastError.message, + }); + + candidates = excludeCandidate(candidates, provider.name, model.id); + } + } + + throw new Error( + `All providers exhausted after ${this.maxRetries} attempts. Last error: ${lastError?.message ?? 'unknown'}` + ); + } + + /** + * Handle explicit provider:model routing (bypass classifier). + */ + private async chatWithExplicitModel( + request: ChatCompletionRequest, + startTime: number, + plan?: RoutePlan + ): Promise { + const resolved = plan ?? this.plan(request); + const provider = resolved.provider; + const modelId = resolved.model.id; + + try { + const result = await sendChatCompletion(provider, modelId, request, this.timeoutMs); + + if (result.status === 429) { + this.health.record(provider.name, modelId, { + timestamp: Date.now(), + latencyMs: result.latencyMs, + status: 'rate_limit', + }); + + this.log({ + event: 'rate_limit', + provider: provider.name, + model: modelId, + attempt: 1, + latencyMs: result.latencyMs, + category: 'explicit', + }); + + throw new Error(`Rate limited by ${provider.name} for model ${modelId}`); + } + + this.health.record(provider.name, modelId, { + timestamp: Date.now(), + latencyMs: result.latencyMs, + status: 'success', + }); + + this.log({ + event: 'success', + provider: provider.name, + model: modelId, + attempt: 1, + latencyMs: result.latencyMs, + category: 'explicit', + tokens: result.response.usage?.total_tokens, + }); + + return { + response: result.response, + provider: provider.name, + model: modelId, + totalLatencyMs: Date.now() - startTime, + attempts: 1, + }; + } catch (err) { + // Re-throw rate-limit errors (already logged above) + if (err instanceof Error && err.message.startsWith('Rate limited by')) { + throw err; + } + + const latency = Date.now() - startTime; + this.health.record(provider.name, modelId, { + timestamp: Date.now(), + latencyMs: latency, + status: 'error', + }); + + this.log({ + event: 'error', + provider: provider.name, + model: modelId, + attempt: 1, + latencyMs: latency, + category: 'explicit', + error: err instanceof Error ? err.message : String(err), + }); + + throw err; + } + } + + plan(request: ChatCompletionRequest): RoutePlan { + return this.planInternal(request, true); + } + + private planInternal(request: ChatCompletionRequest, advanceRoundRobin: boolean): RoutePlan { + const explicit = this.resolveExplicitModel(request.model); + if (explicit) { + return { + provider: explicit.provider, + model: explicit.model, + category: 'explicit', + explicit: true, + }; + } + + const classification = classifyPrompt(request.messages); + const candidates = selectCandidates(this.providers, classification.category, this.health); + + if (candidates.length === 0) { + throw new Error('No healthy providers available for routing'); + } + + const pick = advanceRoundRobin + ? pickNext(candidates, this.roundRobinState) + : (candidates[0] ?? null); + if (!pick) { + throw new Error('No provider available for routing'); + } + + return { + provider: pick.provider, + model: pick.model, + category: classification.category, + explicit: false, + }; + } + + private resolveExplicitModel( + model?: string + ): { provider: ProviderConfig; model: RoutePlan['model'] } | null { + if (!model) return null; + + if (model.includes(':') || model.includes('/')) { + const { providerName, modelId } = parseExplicitModel(model); + const provider = this.providers.find(p => p.name === providerName); + if (!provider) { + throw new Error( + `Provider "${providerName}" not found. Available: ${this.providers.map(p => p.name).join(', ')}` + ); + } + + const matchedModel = provider.models.find(candidate => candidate.id === modelId); + if (!matchedModel) { + throw new Error( + `Model "${modelId}" not found for provider "${providerName}". Available: ${provider.models + .map(candidate => candidate.id) + .join(', ')}` + ); + } + + return { provider, model: matchedModel }; + } + + const matches = this.providers.flatMap(provider => + provider.models + .filter(candidate => candidate.id === model) + .map(candidate => ({ provider, model: candidate })) + ); + + if (matches.length === 1) { + return matches[0]!; + } + + if (matches.length > 1) { + throw new Error( + `Model "${model}" is available on multiple providers. Use provider:model format instead.` + ); + } + + return null; + } + + /** Get health snapshots for all tracked provider+model pairs. */ + getHealth(): HealthSnapshot[] { + return this.health.allSnapshots(); + } + + /** Get list of available (configured) providers. */ + getProviders(): string[] { + return this.providers.map(p => p.name); + } + + /** Reset health tracking data. */ + resetHealth(): void { + this.health.reset(); + } +} + +function parseExplicitModel(raw: string): { providerName: string; modelId: string } { + const colonIdx = raw.indexOf(':'); + const slashIdx = raw.indexOf('/'); + let sepIdx: number; + if (colonIdx === -1 && slashIdx === -1) { + sepIdx = -1; + } else if (colonIdx === -1) { + sepIdx = slashIdx; + } else if (slashIdx === -1) { + sepIdx = colonIdx; + } else { + sepIdx = Math.min(colonIdx, slashIdx); + } + + return { + providerName: sepIdx === -1 ? raw : raw.slice(0, sepIdx), + modelId: sepIdx === -1 ? '' : raw.slice(sepIdx + 1), + }; +} + +// ── Telemetry types ──────────────────────────────────────────── + +export interface TelemetryEntry { + event: 'success' | 'rate_limit' | 'error'; + provider: string; + model: string; + attempt: number; + latencyMs: number; + category: string; + tokens?: number; + error?: string; +} diff --git a/vendor/bytelyst/llm-router/src/selector.ts b/vendor/bytelyst/llm-router/src/selector.ts new file mode 100644 index 0000000..48c3821 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/selector.ts @@ -0,0 +1,108 @@ +import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; +import type { HealthTracker } from './health.js'; + +export interface SelectionCandidate { + provider: ProviderConfig; + model: ModelConfig; +} + +/** Create a fresh round-robin state map (one per router instance). */ +export function createRoundRobinState(): Map { + return new Map(); +} + +/** + * Score a model for a given prompt category. + * Higher = better fit. + */ +function scoreModel(model: ModelConfig, category: PromptCategory): number { + let score = 0; + + // Vision requests require vision-capable models + if (category === 'vision') { + if (!model.supportsVision) return -1; // Exclude non-vision models + score += 15; // Strong boost for vision capability + } + + // Direct strength match is the strongest signal + if (model.strengths.includes(category)) { + score += 10; + } + + // Speed bonus (lower tier = faster = better for simple tasks) + score += (4 - model.speedTier) * 2; + + // Context window bonus for reasoning/creative (often longer) + if ((category === 'reasoning' || category === 'creative') && model.contextWindow >= 64_000) { + score += 3; + } + + // Prefer larger models for code/math/reasoning + if (['code', 'math', 'reasoning'].includes(category)) { + if (model.id.includes('70b') || model.id.includes('70B')) score += 5; + if (model.id.includes('r1') || model.id.includes('R1')) score += 4; + } + + return score; +} + +/** + * Select the best provider+model candidates for a prompt category. + * Returns candidates sorted by score (best first), filtered by health. + */ +export function selectCandidates( + providers: ProviderConfig[], + category: PromptCategory, + health: HealthTracker +): SelectionCandidate[] { + const candidates: (SelectionCandidate & { score: number })[] = []; + + for (const provider of providers) { + for (const model of provider.models) { + if (!health.isHealthy(provider.name, model.id)) continue; + + const score = scoreModel(model, category); + if (score < 0) continue; // Skip incompatible models (e.g. non-vision for vision requests) + candidates.push({ provider, model, score }); + } + } + + // Sort by score descending + candidates.sort((a, b) => b.score - a.score); + + return candidates; +} + +/** + * Pick the next candidate using round-robin within the top tier. + * Groups candidates by provider, rotates between them to spread rate-limit load. + */ +export function pickNext( + candidates: SelectionCandidate[], + state: Map +): SelectionCandidate | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]!; + + // Group by provider name for round-robin + const providerNames = [...new Set(candidates.map(c => c.provider.name))]; + const key = providerNames.join(','); + + const idx = state.get(key) ?? 0; + const targetProvider = providerNames[idx % providerNames.length]!; + state.set(key, idx + 1); + + // Pick the best model from the selected provider + return candidates.find(c => c.provider.name === targetProvider) ?? candidates[0]!; +} + +/** + * Remove a candidate from the list (after failure) and return remaining. + */ +export function excludeCandidate( + candidates: SelectionCandidate[], + provider: string, + model: string +): SelectionCandidate[] { + return candidates.filter(c => !(c.provider.name === provider && c.model.id === model)); +} diff --git a/vendor/bytelyst/llm-router/src/types.ts b/vendor/bytelyst/llm-router/src/types.ts new file mode 100644 index 0000000..4831291 --- /dev/null +++ b/vendor/bytelyst/llm-router/src/types.ts @@ -0,0 +1,147 @@ +// ── Provider & Model Types ───────────────────────────────────── + +export interface ModelConfig { + /** Model identifier as the provider expects it */ + id: string; + /** Human-readable label */ + label: string; + /** Max context window tokens */ + contextWindow: number; + /** What this model is good at */ + strengths: PromptCategory[]; + /** Relative speed tier: 1 = fastest, 3 = slowest */ + speedTier: 1 | 2 | 3; + /** Whether the model supports vision (image) inputs */ + supportsVision?: boolean; + /** Whether the model supports text embedding generation */ + supportsEmbedding?: boolean; +} + +export interface ProviderConfig { + /** Unique provider name */ + name: string; + /** OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1) */ + baseUrl: string; + /** Environment variable name that holds the API key (omit for local/no-auth providers) */ + apiKeyEnv?: string; + /** Available models on this provider */ + models: ModelConfig[]; + /** Extra headers to send with every request */ + extraHeaders?: Record; + /** Free-tier rate limit: requests per minute (0 = unknown) */ + rpmLimit: number; + /** Free-tier rate limit: tokens per minute (0 = unknown) */ + tpmLimit: number; +} + +// ── Prompt Classification ────────────────────────────────────── + +export type PromptCategory = 'code' | 'math' | 'reasoning' | 'creative' | 'general' | 'vision'; + +export interface ClassificationResult { + category: PromptCategory; + estimatedTokens: number; +} + +// ── Health Tracking ──────────────────────────────────────────── + +export interface HealthSnapshot { + provider: string; + model: string; + /** Total requests in the window */ + totalRequests: number; + /** Successful requests */ + successes: number; + /** 429 rate-limit hits */ + rateLimits: number; + /** 5xx / network errors */ + errors: number; + /** Average latency in ms (successes only) */ + avgLatencyMs: number; + /** p95 latency in ms */ + p95LatencyMs: number; + /** Whether this provider is currently considered healthy */ + healthy: boolean; +} + +export interface RequestRecord { + timestamp: number; + latencyMs: number; + status: 'success' | 'rate_limit' | 'error'; +} + +// ── Router Config ────────────────────────────────────────────── + +export interface RouterConfig { + /** Provider configurations (use DEFAULT_PROVIDERS if omitted) */ + providers?: ProviderConfig[]; + /** Health window in ms (default: 60_000 = 1 minute) */ + healthWindowMs?: number; + /** Error rate threshold to mark unhealthy (default: 0.5 = 50%) */ + errorThreshold?: number; + /** Rate-limit rate threshold to mark unhealthy (default: 0.3 = 30%) */ + rateLimitThreshold?: number; + /** Request timeout in ms (default: 30_000) */ + timeoutMs?: number; + /** Max retry attempts across providers (default: 3) */ + maxRetries?: number; +} + +// ── OpenAI-Compatible Request/Response ───────────────────────── + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ChatCompletionRequest { + messages: ChatMessage[]; + /** Optional: force a specific model (provider:model format or just model id) */ + model?: string; + temperature?: number; + max_tokens?: number; + top_p?: number; + stream?: boolean; +} + +export interface ChatCompletionChoice { + index: number; + message: ChatMessage; + finish_reason: string | null; +} + +export interface ChatCompletionUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface ChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage?: ChatCompletionUsage; +} + +// ── Router Result (wraps response + metadata) ────────────────── + +export interface RouteResult { + response: ChatCompletionResponse; + /** Which provider served this request */ + provider: string; + /** Which model was used */ + model: string; + /** Total latency in ms (including retries) */ + totalLatencyMs: number; + /** How many attempts were made */ + attempts: number; +} + +export interface RoutePlan { + provider: ProviderConfig; + model: ModelConfig; + category: PromptCategory | 'explicit'; + explicit: boolean; +} diff --git a/vendor/bytelyst/llm-router/tsconfig.json b/vendor/bytelyst/llm-router/tsconfig.json new file mode 100644 index 0000000..8635ab2 --- /dev/null +++ b/vendor/bytelyst/llm-router/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/llm-router/vitest.config.ts b/vendor/bytelyst/llm-router/vitest.config.ts new file mode 100644 index 0000000..811c18a --- /dev/null +++ b/vendor/bytelyst/llm-router/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + passWithNoTests: true, + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/logger/package.json b/vendor/bytelyst/logger/package.json new file mode 100644 index 0000000..20446d0 --- /dev/null +++ b/vendor/bytelyst/logger/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/logger", + "version": "0.1.5", + "description": "Structured logger factory for Next.js dashboards and Node.js services", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/logger/src/__tests__/logger.test.ts b/vendor/bytelyst/logger/src/__tests__/logger.test.ts new file mode 100644 index 0000000..4d44a77 --- /dev/null +++ b/vendor/bytelyst/logger/src/__tests__/logger.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for @bytelyst/logger package — createLogger + structured logging. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createLogger } from '../logger.js'; +import type { Logger } from '../types.js'; + +describe('createLogger', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stdoutSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stderrSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let consoleSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let consoleErrorSpy: any; + + beforeEach(() => { + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns a Logger with all four log methods', () => { + const log = createLogger({ service: 'test-service' }); + expect(typeof log.error).toBe('function'); + expect(typeof log.warn).toBe('function'); + expect(typeof log.info).toBe('function'); + expect(typeof log.debug).toBe('function'); + }); + + describe('production mode (isDev: false)', () => { + let log: Logger; + + beforeEach(() => { + log = createLogger({ service: 'platform-service', isDev: false }); + }); + + it('info writes structured JSON to stdout', () => { + log.info('Server started', { port: 4003 }); + + expect(stdoutSpy).toHaveBeenCalledTimes(1); + const output = stdoutSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + + expect(parsed.level).toBe('info'); + expect(parsed.message).toBe('Server started'); + expect(parsed.service).toBe('platform-service'); + expect(parsed.port).toBe(4003); + expect(parsed.timestamp).toBeDefined(); + }); + + it('error writes structured JSON to stderr', () => { + log.error('Connection failed', new Error('timeout')); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + + expect(parsed.level).toBe('error'); + expect(parsed.message).toBe('Connection failed'); + expect(parsed.error).toBe('timeout'); + expect(parsed.stack).toBeDefined(); + expect(parsed.service).toBe('platform-service'); + }); + + it('warn writes structured JSON to stderr', () => { + log.warn('High memory usage', { usageMb: 512 }); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + + expect(parsed.level).toBe('warn'); + expect(parsed.message).toBe('High memory usage'); + expect(parsed.usageMb).toBe(512); + }); + + it('debug does NOT emit in production mode', () => { + log.debug('Debug info'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('error handles non-Error objects', () => { + log.error('Strange error', 'just a string'); + + const output = stderrSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.error).toBe('just a string'); + expect(parsed.stack).toBeUndefined(); + }); + + it('error handles undefined error', () => { + log.error('No error object'); + + const output = stderrSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.error).toBeUndefined(); + }); + + it('includes timestamp in ISO format', () => { + log.info('Timestamp test'); + + const output = stdoutSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output.trim()); + expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('appends newline to output', () => { + log.info('Newline check'); + + const output = stdoutSpy.mock.calls[0][0] as string; + expect(output.endsWith('\n')).toBe(true); + }); + }); + + describe('dev mode (isDev: true)', () => { + let log: Logger; + + beforeEach(() => { + log = createLogger({ service: 'dev-service', isDev: true }); + }); + + it('info uses console.log with prefix', () => { + log.info('Dev message'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('[INFO]'); + expect(output).toContain('Dev message'); + }); + + it('error uses console.error with prefix', () => { + log.error('Dev error', new Error('boom')); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + const output = consoleErrorSpy.mock.calls[0][0] as string; + expect(output).toContain('[ERROR]'); + expect(output).toContain('Dev error'); + expect(output).toContain('boom'); + }); + + it('warn uses console.error with prefix', () => { + log.warn('Dev warning'); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + const output = consoleErrorSpy.mock.calls[0][0] as string; + expect(output).toContain('[WARN]'); + expect(output).toContain('Dev warning'); + }); + + it('debug emits in dev mode', () => { + log.debug('Debug details', { key: 'value' }); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('[DEBUG]'); + expect(output).toContain('Debug details'); + }); + + it('includes extra data in dev mode', () => { + log.info('With extras', { requestId: 'abc', userId: 'u1' }); + + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('requestId'); + }); + }); + + describe('config defaults', () => { + it('defaults to dev mode when NODE_ENV is not production', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + const log = createLogger({ service: 'test' }); + log.debug('Should emit in dev'); + + // Debug should emit in non-production + expect(consoleSpy.mock.calls.length + stdoutSpy.mock.calls.length).toBeGreaterThan(0); + + process.env.NODE_ENV = originalEnv; + }); + }); +}); diff --git a/vendor/bytelyst/logger/src/index.ts b/vendor/bytelyst/logger/src/index.ts new file mode 100644 index 0000000..dcd5596 --- /dev/null +++ b/vendor/bytelyst/logger/src/index.ts @@ -0,0 +1,2 @@ +export { createLogger } from './logger.js'; +export type { Logger, LoggerConfig, LogLevel, LogPayload } from './types.js'; diff --git a/vendor/bytelyst/logger/src/logger.ts b/vendor/bytelyst/logger/src/logger.ts new file mode 100644 index 0000000..6e3d034 --- /dev/null +++ b/vendor/bytelyst/logger/src/logger.ts @@ -0,0 +1,83 @@ +import type { LogLevel, LogPayload, Logger, LoggerConfig } from './types.js'; + +function formatPayload( + service: string, + level: LogLevel, + message: string, + error?: unknown, + extra?: Record +): LogPayload { + const payload: LogPayload = { + level, + message, + timestamp: new Date().toISOString(), + service, + ...extra, + }; + + if (error instanceof Error) { + payload.error = error.message; + payload.stack = error.stack; + } else if (error !== undefined) { + payload.error = String(error); + } + + return payload; +} + +function emit(payload: LogPayload, isDev: boolean): void { + if (isDev) { + const { level, message, error: err, ...rest } = payload; + const prefix = `[${level.toUpperCase()}]`; + const extras = Object.keys(rest).length > 2 ? ` ${JSON.stringify(rest)}` : ''; + const errStr = err ? ` — ${err}` : ''; + + if (level === 'error' || level === 'warn') { + // eslint-disable-next-line no-console + console.error(`${prefix} ${message}${errStr}${extras}`); + } else { + // eslint-disable-next-line no-console + console.log(`${prefix} ${message}${extras}`); + } + } else { + const out = JSON.stringify(payload); + if (payload.level === 'error' || payload.level === 'warn') { + process.stderr.write(out + '\n'); + } else { + process.stdout.write(out + '\n'); + } + } +} + +/** + * Create a structured logger for a service or dashboard. + * + * @example + * ```ts + * import { createLogger } from "@bytelyst/logger"; + * const log = createLogger({ service: "admin-dashboard" }); + * log.error("Login failed", error, { userId }); + * log.info("Seed complete", { containers: 8 }); + * ``` + */ +export function createLogger(config: LoggerConfig): Logger { + const { service } = config; + const isDev = config.isDev ?? process.env.NODE_ENV !== 'production'; + + return { + error(message: string, error?: unknown, extra?: Record): void { + emit(formatPayload(service, 'error', message, error, extra), isDev); + }, + warn(message: string, extra?: Record): void { + emit(formatPayload(service, 'warn', message, undefined, extra), isDev); + }, + info(message: string, extra?: Record): void { + emit(formatPayload(service, 'info', message, undefined, extra), isDev); + }, + debug(message: string, extra?: Record): void { + if (isDev) { + emit(formatPayload(service, 'debug', message, undefined, extra), isDev); + } + }, + }; +} diff --git a/vendor/bytelyst/logger/src/types.ts b/vendor/bytelyst/logger/src/types.ts new file mode 100644 index 0000000..eb32ef9 --- /dev/null +++ b/vendor/bytelyst/logger/src/types.ts @@ -0,0 +1,25 @@ +export type LogLevel = 'info' | 'warn' | 'error' | 'debug'; + +export interface LogPayload { + level: LogLevel; + message: string; + timestamp: string; + service: string; + error?: string; + stack?: string; + [key: string]: unknown; +} + +export interface LoggerConfig { + /** Service name included in every log entry (e.g. "admin-dashboard", "billing-service") */ + service: string; + /** Override NODE_ENV detection for dev/prod mode. Default: reads process.env.NODE_ENV */ + isDev?: boolean; +} + +export interface Logger { + error(message: string, error?: unknown, extra?: Record): void; + warn(message: string, extra?: Record): void; + info(message: string, extra?: Record): void; + debug(message: string, extra?: Record): void; +} diff --git a/vendor/bytelyst/logger/tsconfig.json b/vendor/bytelyst/logger/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/vendor/bytelyst/logger/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/marketplace-client/package.json b/vendor/bytelyst/marketplace-client/package.json new file mode 100644 index 0000000..4036336 --- /dev/null +++ b/vendor/bytelyst/marketplace-client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/marketplace-client", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/marketplace-client/src/client.test.ts b/vendor/bytelyst/marketplace-client/src/client.test.ts new file mode 100644 index 0000000..b1b80ef --- /dev/null +++ b/vendor/bytelyst/marketplace-client/src/client.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createMarketplaceClient } from './client.js'; +import type { + MarketplaceListingDoc, + MarketplaceReviewDoc, + MarketplaceInstallDoc, +} from './types.js'; + +const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', +}; + +function mockListing(overrides?: Partial): MarketplaceListingDoc { + return { + id: 'lst_1', + productId: 'testapp', + templateType: 'fasting_protocol', + authorId: 'user-1', + authorName: 'Alice', + title: '16:8 Protocol', + shortDescription: 'Classic intermittent fasting', + description: 'A detailed 16:8 fasting protocol.', + tags: ['fasting', 'beginner'], + category: 'protocols', + payload: { hours: 16, breakHours: 8 }, + pricingModel: 'free', + priceInCents: 0, + certificationStatus: 'approved', + installCount: 100, + reviewCount: 10, + averageRating: 4.5, + visibility: 'public', + featured: false, + version: '1.0.0', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockReview(overrides?: Partial): MarketplaceReviewDoc { + return { + id: 'rev_1', + listingId: 'lst_1', + productId: 'testapp', + authorId: 'user-2', + rating: 5, + title: 'Great protocol!', + body: 'Really works well for me.', + verified: true, + createdAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockInstall(overrides?: Partial): MarketplaceInstallDoc { + return { + id: 'inst_1', + listingId: 'lst_1', + productId: 'testapp', + userId: 'user-2', + version: '1.0.0', + installedAt: '2026-01-01T00:00:00Z', + uninstalledAt: null, + ...overrides, + }; +} + +describe('createMarketplaceClient', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should list listings', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ listings: [mockListing()], total: 1 }), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.listListings({ category: 'protocols' }); + expect(result.listings).toHaveLength(1); + expect(result.total).toBe(1); + + const fetchMock = globalThis.fetch as ReturnType; + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('category=protocols'); + }); + + it('should get a listing by id', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockListing()), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.getListing('lst_1'); + expect(result.title).toBe('16:8 Protocol'); + }); + + it('should create a listing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockListing()), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.createListing({ + templateType: 'fasting_protocol', + title: '16:8 Protocol', + shortDescription: 'Classic', + description: 'Detailed', + category: 'protocols', + payload: { hours: 16 }, + }); + expect(result.id).toBe('lst_1'); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/marketplace/listings'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should submit for certification', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockListing({ certificationStatus: 'submitted' })), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.submitForCertification('lst_1', 'Ready for review'); + expect(result.certificationStatus).toBe('submitted'); + }); + + it('should install a listing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockInstall()), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.installListing('lst_1'); + expect(result.listingId).toBe('lst_1'); + }); + + it('should list my installs', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([mockInstall()]), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.listMyInstalls(); + expect(result).toHaveLength(1); + }); + + it('should list reviews', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([mockReview()]), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.listReviews('lst_1'); + expect(result).toHaveLength(1); + expect(result[0].rating).toBe(5); + }); + + it('should create a review', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReview()), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.createReview('lst_1', { + rating: 5, + title: 'Great!', + body: 'Love it.', + }); + expect(result.rating).toBe(5); + }); + + it('should update a listing', async () => { + const updated = mockListing({ title: 'Updated Title' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(updated), + }) + ); + const client = createMarketplaceClient(baseConfig); + const result = await client.updateListing('lst_1', { title: 'Updated Title' }); + expect(result.title).toBe('Updated Title'); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/marketplace/listings/lst_1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + + it('should uninstall a listing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + }) + ); + const client = createMarketplaceClient(baseConfig); + await expect(client.uninstallListing('lst_1')).resolves.toBeUndefined(); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/marketplace/listings/lst_1/uninstall', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should report a listing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + }) + ); + const client = createMarketplaceClient(baseConfig); + await expect( + client.reportListing('lst_1', { reason: 'spam', details: 'Looks like spam' }) + ).resolves.toBeUndefined(); + }); + + it('should throw on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + const client = createMarketplaceClient(baseConfig); + await expect(client.getListing('lst_1')).rejects.toThrow('getListing failed: 500'); + }); + + it('should send correct headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ listings: [], total: 0 }), + }) + ); + const client = createMarketplaceClient(baseConfig); + await client.listListings(); + + const fetchMock = globalThis.fetch as ReturnType; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer test-token'); + expect(callHeaders['x-request-id']).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/marketplace-client/src/client.ts b/vendor/bytelyst/marketplace-client/src/client.ts new file mode 100644 index 0000000..bf3a63e --- /dev/null +++ b/vendor/bytelyst/marketplace-client/src/client.ts @@ -0,0 +1,219 @@ +/** + * Browser/React Native-safe marketplace client for platform-service. + * + * Wraps platform-service /marketplace/* endpoints. + * No Node.js dependencies — uses globalThis.fetch. + */ + +import type { + CreateListingInput, + MarketplaceClient, + MarketplaceClientConfig, + MarketplaceInstallDoc, + MarketplaceListingDoc, + MarketplaceReviewDoc, +} from './types.js'; + +function generateRequestId(): string { + return typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createMarketplaceClient(config: MarketplaceClientConfig): MarketplaceClient { + const { baseUrl, productId, getAccessToken } = config; + + function headers(): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': generateRequestId(), + }; + const token = getAccessToken(); + if (token) h['Authorization'] = `Bearer ${token}`; + return h; + } + + // ── Listings ────────────────────────────────────── + + async function listListings(query?: { + templateType?: string; + category?: string; + tags?: string; + pricingModel?: string; + sortBy?: string; + q?: string; + limit?: number; + offset?: number; + }): Promise<{ listings: MarketplaceListingDoc[]; total: number }> { + const params = new URLSearchParams(); + if (query?.templateType) params.set('templateType', query.templateType); + if (query?.category) params.set('category', query.category); + if (query?.tags) params.set('tags', query.tags); + if (query?.pricingModel) params.set('pricingModel', query.pricingModel); + if (query?.sortBy) params.set('sortBy', query.sortBy); + if (query?.q) params.set('q', query.q); + if (query?.limit) params.set('limit', String(query.limit)); + if (query?.offset) params.set('offset', String(query.offset)); + const qs = params.toString(); + const url = qs ? `${baseUrl}/marketplace/listings?${qs}` : `${baseUrl}/marketplace/listings`; + const res = await globalThis.fetch(url, { headers: headers() }); + if (!res.ok) throw new Error(`listListings failed: ${res.status}`); + return (await res.json()) as { listings: MarketplaceListingDoc[]; total: number }; + } + + async function getListing(id: string): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, + { headers: headers() } + ); + if (!res.ok) throw new Error(`getListing failed: ${res.status}`); + return (await res.json()) as MarketplaceListingDoc; + } + + async function createListing(input: CreateListingInput): Promise { + const res = await globalThis.fetch(`${baseUrl}/marketplace/listings`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`createListing failed: ${res.status}`); + return (await res.json()) as MarketplaceListingDoc; + } + + async function updateListing( + id: string, + updates: Partial + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, + { + method: 'PATCH', + headers: headers(), + body: JSON.stringify(updates), + } + ); + if (!res.ok) throw new Error(`updateListing failed: ${res.status}`); + return (await res.json()) as MarketplaceListingDoc; + } + + async function submitForCertification( + id: string, + notes?: string + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}/submit`, + { + method: 'POST', + headers: headers(), + body: JSON.stringify({ notes }), + } + ); + if (!res.ok) throw new Error(`submitForCertification failed: ${res.status}`); + return (await res.json()) as MarketplaceListingDoc; + } + + // ── Installs ────────────────────────────────────── + + async function installListing(listingId: string): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/install`, + { + method: 'POST', + headers: headers(), + } + ); + if (!res.ok) throw new Error(`installListing failed: ${res.status}`); + return (await res.json()) as MarketplaceInstallDoc; + } + + async function uninstallListing(listingId: string): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/uninstall`, + { + method: 'POST', + headers: headers(), + } + ); + if (!res.ok) throw new Error(`uninstallListing failed: ${res.status}`); + } + + async function listMyInstalls(query?: { + limit?: number; + offset?: number; + }): Promise { + const params = new URLSearchParams(); + if (query?.limit) params.set('limit', String(query.limit)); + if (query?.offset) params.set('offset', String(query.offset)); + const qs = params.toString(); + const url = qs ? `${baseUrl}/marketplace/installs?${qs}` : `${baseUrl}/marketplace/installs`; + const res = await globalThis.fetch(url, { headers: headers() }); + if (!res.ok) throw new Error(`listMyInstalls failed: ${res.status}`); + return (await res.json()) as MarketplaceInstallDoc[]; + } + + // ── Reviews ─────────────────────────────────────── + + async function listReviews( + listingId: string, + query?: { sortBy?: string; limit?: number } + ): Promise { + const params = new URLSearchParams(); + if (query?.sortBy) params.set('sortBy', query.sortBy); + if (query?.limit) params.set('limit', String(query.limit)); + const qs = params.toString(); + const url = qs + ? `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews?${qs}` + : `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`; + const res = await globalThis.fetch(url, { headers: headers() }); + if (!res.ok) throw new Error(`listReviews failed: ${res.status}`); + return (await res.json()) as MarketplaceReviewDoc[]; + } + + async function createReview( + listingId: string, + input: { rating: number; title: string; body: string } + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`, + { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + } + ); + if (!res.ok) throw new Error(`createReview failed: ${res.status}`); + return (await res.json()) as MarketplaceReviewDoc; + } + + // ── Reports ─────────────────────────────────────── + + async function reportListing( + listingId: string, + input: { reason: string; details: string } + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/report`, + { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + } + ); + if (!res.ok) throw new Error(`reportListing failed: ${res.status}`); + } + + return { + listListings, + getListing, + createListing, + updateListing, + submitForCertification, + installListing, + uninstallListing, + listMyInstalls, + listReviews, + createReview, + reportListing, + }; +} diff --git a/vendor/bytelyst/marketplace-client/src/index.ts b/vendor/bytelyst/marketplace-client/src/index.ts new file mode 100644 index 0000000..10febd0 --- /dev/null +++ b/vendor/bytelyst/marketplace-client/src/index.ts @@ -0,0 +1,75 @@ +export interface MarketplaceClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface MarketplaceListing { + id: string; + title: string; + description: string; + category: string; + author: string; + downloads: number; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: MarketplaceClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + "Content-Type": "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createMarketplaceClient(opts: MarketplaceClientOptions) { + const { baseUrl } = opts; + + return { + async listListings(listOpts?: { + category?: string; + }): Promise { + const q = new URLSearchParams(); + if (listOpts?.category !== undefined && listOpts.category !== "") { + q.set("category", listOpts.category); + } + const query = q.toString(); + const path = + query.length > 0 + ? `/marketplace/listings?${query}` + : "/marketplace/listings"; + const res = await fetch(joinUrl(baseUrl, path), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + async installListing( + listingId: string + ): Promise<{ success: boolean }> { + const res = await fetch( + joinUrl( + baseUrl, + `/marketplace/listings/${encodeURIComponent(listingId)}/install` + ), + { method: "POST", headers: headers(opts), body: "{}" } + ); + return parseJson<{ success: boolean }>(res); + }, + }; +} diff --git a/vendor/bytelyst/marketplace-client/src/types.ts b/vendor/bytelyst/marketplace-client/src/types.ts new file mode 100644 index 0000000..482950d --- /dev/null +++ b/vendor/bytelyst/marketplace-client/src/types.ts @@ -0,0 +1,115 @@ +/** + * Types for @bytelyst/marketplace-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface MarketplaceListingDoc { + id: string; + productId: string; + templateType: string; + authorId: string; + authorName: string; + title: string; + shortDescription: string; + description: string; + tags: string[]; + category: string; + payload: Record; + pricingModel: 'free' | 'paid' | 'freemium'; + priceInCents: number; + certificationStatus: 'draft' | 'submitted' | 'in_review' | 'approved' | 'rejected' | 'suspended'; + installCount: number; + reviewCount: number; + averageRating: number; + visibility: 'private' | 'unlisted' | 'public'; + featured: boolean; + version: string; + createdAt: string; + updatedAt: string; +} + +export interface MarketplaceReviewDoc { + id: string; + listingId: string; + productId: string; + authorId: string; + rating: number; + title: string; + body: string; + verified: boolean; + createdAt: string; +} + +export interface MarketplaceInstallDoc { + id: string; + listingId: string; + productId: string; + userId: string; + version: string; + installedAt: string; + uninstalledAt: string | null; +} + +export interface CreateListingInput { + templateType: string; + title: string; + shortDescription: string; + description: string; + tags?: string[]; + category: string; + payload: Record; + pricingModel?: 'free' | 'paid' | 'freemium'; + priceInCents?: number; + visibility?: 'private' | 'unlisted' | 'public'; + version?: string; +} + +export interface MarketplaceClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header on every request. */ + productId: string; + + /** Returns a JWT access token, or null if not authenticated. */ + getAccessToken: () => string | null; +} + +export interface MarketplaceClient { + // Listings + listListings(query?: { + templateType?: string; + category?: string; + tags?: string; + pricingModel?: string; + sortBy?: string; + q?: string; + limit?: number; + offset?: number; + }): Promise<{ listings: MarketplaceListingDoc[]; total: number }>; + getListing(id: string): Promise; + createListing(input: CreateListingInput): Promise; + updateListing( + id: string, + updates: Partial + ): Promise; + submitForCertification(id: string, notes?: string): Promise; + + // Installs + installListing(listingId: string): Promise; + uninstallListing(listingId: string): Promise; + listMyInstalls(query?: { limit?: number; offset?: number }): Promise; + + // Reviews + listReviews( + listingId: string, + query?: { sortBy?: string; limit?: number } + ): Promise; + createReview( + listingId: string, + input: { rating: number; title: string; body: string } + ): Promise; + + // Reports + reportListing(listingId: string, input: { reason: string; details: string }): Promise; +} diff --git a/vendor/bytelyst/marketplace-client/tsconfig.json b/vendor/bytelyst/marketplace-client/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/marketplace-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/monitoring/package.json b/vendor/bytelyst/monitoring/package.json new file mode 100644 index 0000000..1d9b877 --- /dev/null +++ b/vendor/bytelyst/monitoring/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/monitoring", + "version": "0.1.5", + "type": "module", + "description": "Health-check aggregation utilities for ByteLyst services and dashboards", + "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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts b/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts new file mode 100644 index 0000000..b48f4dd --- /dev/null +++ b/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { checkService, generateHealthReport, type ServiceTarget } from '../index.js'; + +type FetchInput = Parameters[0]; +type MockFetchResult = Pick; + +function mockFetch(handler: (input: FetchInput) => Promise): void { + globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; +} + +describe('monitoring', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('checkService returns healthy for 200', async () => { + mockFetch(async () => ({ + ok: true, + status: 200, + json: async () => ({ status: 'ok' }), + })); + + const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); + expect(res.status).toBe('healthy'); + expect(res.details).toEqual({ status: 'ok' }); + }); + + it('checkService returns unhealthy for non-2xx', async () => { + mockFetch(async () => ({ + ok: false, + status: 503, + json: async () => ({}), + })); + + const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); + expect(res.status).toBe('unhealthy'); + expect(res.error).toContain('HTTP 503'); + }); + + it('checkService returns unreachable on fetch error', async () => { + mockFetch(async () => { + throw new Error('network'); + }); + + const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); + expect(res.status).toBe('unreachable'); + expect(res.error).toContain('network'); + }); + + it('generateHealthReport sets overall=down when all unreachable', async () => { + mockFetch(async () => { + throw new Error('network'); + }); + + const services: ServiceTarget[] = [ + { name: 'a', url: 'http://a', path: '/health' }, + { name: 'b', url: 'http://b', path: '/health' }, + ]; + + const report = await generateHealthReport(services, { timeoutMs: 10 }); + expect(report.overall).toBe('down'); + expect(report.summary.unreachable).toBe(2); + }); + + it('generateHealthReport sets overall=degraded when mixed', async () => { + mockFetch(async url => { + if (String(url).includes('good')) return { ok: true, status: 200, json: async () => ({}) }; + return { ok: false, status: 500, json: async () => ({}) }; + }); + + const services: ServiceTarget[] = [ + { name: 'good', url: 'http://good', path: '/health' }, + { name: 'bad', url: 'http://bad', path: '/health' }, + ]; + + const report = await generateHealthReport(services); + expect(report.overall).toBe('degraded'); + expect(report.summary.healthy).toBe(1); + expect(report.summary.unhealthy).toBe(1); + }); + + it('generateHealthReport sets overall=healthy when all healthy', async () => { + mockFetch(async () => ({ + ok: true, + status: 200, + json: async () => ({}), + })); + + const services: ServiceTarget[] = [ + { name: 'a', url: 'http://a', path: '/health' }, + { name: 'b', url: 'http://b', path: '/health' }, + ]; + + const report = await generateHealthReport(services); + expect(report.overall).toBe('healthy'); + expect(report.summary.healthy).toBe(2); + }); +}); diff --git a/vendor/bytelyst/monitoring/src/health.ts b/vendor/bytelyst/monitoring/src/health.ts new file mode 100644 index 0000000..6e99207 --- /dev/null +++ b/vendor/bytelyst/monitoring/src/health.ts @@ -0,0 +1,105 @@ +export interface ServiceTarget { + name: string; + url: string; + path: string; +} + +export interface ServiceCheck { + name: string; + url: string; + status: 'healthy' | 'unhealthy' | 'unreachable'; + responseTimeMs: number; + details?: Record; + error?: string; +} + +export interface HealthReport { + overall: 'healthy' | 'degraded' | 'down'; + timestamp: string; + services: ServiceCheck[]; + summary: { healthy: number; unhealthy: number; unreachable: number; total: number }; +} + +/** + * Default service targets (LysnrAI local stack). + */ +export const DEFAULT_SERVICES: ServiceTarget[] = [ + { name: 'Backend API', url: process.env.BACKEND_URL || 'http://localhost:8000', path: '/health' }, + { + name: 'Platform Service', + url: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003', + path: '/health', + }, + { + name: 'Admin Dashboard', + url: process.env.ADMIN_DASHBOARD_URL || 'http://localhost:3001', + path: '/api/health', + }, + { + name: 'User Dashboard', + url: process.env.USER_DASHBOARD_URL || 'http://localhost:3002', + path: '/api/health', + }, +]; + +export async function checkService( + svc: ServiceTarget, + opts?: { timeoutMs?: number } +): Promise { + const fullUrl = `${svc.url}${svc.path}`; + const start = performance.now(); + + try { + const res = await fetch(fullUrl, { signal: AbortSignal.timeout(opts?.timeoutMs ?? 5_000) }); + const elapsed = Math.round(performance.now() - start); + + if (res.ok) { + let details: Record | undefined; + try { + details = (await res.json()) as Record; + } catch { + /* ignore */ + } + return { name: svc.name, url: svc.url, status: 'healthy', responseTimeMs: elapsed, details }; + } + + return { + name: svc.name, + url: svc.url, + status: 'unhealthy', + responseTimeMs: elapsed, + error: `HTTP ${res.status}`, + }; + } catch (err) { + const elapsed = Math.round(performance.now() - start); + return { + name: svc.name, + url: svc.url, + status: 'unreachable', + responseTimeMs: elapsed, + error: String(err), + }; + } +} + +export async function generateHealthReport( + services: ServiceTarget[] = DEFAULT_SERVICES, + opts?: { timeoutMs?: number } +): Promise { + const checks = await Promise.all(services.map(svc => checkService(svc, opts))); + + const healthy = checks.filter(c => c.status === 'healthy').length; + const unhealthy = checks.filter(c => c.status === 'unhealthy').length; + const unreachable = checks.filter(c => c.status === 'unreachable').length; + + let overall: HealthReport['overall'] = 'healthy'; + if (unreachable === checks.length) overall = 'down'; + else if (unhealthy > 0 || unreachable > 0) overall = 'degraded'; + + return { + overall, + timestamp: new Date().toISOString(), + services: checks, + summary: { healthy, unhealthy, unreachable, total: checks.length }, + }; +} diff --git a/vendor/bytelyst/monitoring/src/index.ts b/vendor/bytelyst/monitoring/src/index.ts new file mode 100644 index 0000000..9d719a5 --- /dev/null +++ b/vendor/bytelyst/monitoring/src/index.ts @@ -0,0 +1 @@ +export * from './health.js'; diff --git a/vendor/bytelyst/monitoring/tsconfig.json b/vendor/bytelyst/monitoring/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/monitoring/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/offline-queue/package.json b/vendor/bytelyst/offline-queue/package.json new file mode 100644 index 0000000..4aadb6a --- /dev/null +++ b/vendor/bytelyst/offline-queue/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/offline-queue", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe persistent offline retry queue", + "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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/offline-queue/src/index.test.ts b/vendor/bytelyst/offline-queue/src/index.test.ts new file mode 100644 index 0000000..2135622 --- /dev/null +++ b/vendor/bytelyst/offline-queue/src/index.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createOfflineQueue } from './index.js'; + +function createMemoryStorage() { + const store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + _store: store, + }; +} + +describe('createOfflineQueue', () => { + it('should start with zero length', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + expect(queue.length()).toBe(0); + }); + + it('should enqueue items', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } }); + expect(queue.length()).toBe(1); + + queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } }); + expect(queue.length()).toBe(2); + }); + + it('should replace existing entry with same id + action', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } }); + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } }); + expect(queue.length()).toBe(1); + }); + + it('should not replace entries with different action', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} }); + queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} }); + expect(queue.length()).toBe(2); + }); + + it('should flush all items successfully', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } }); + queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } }); + + const executor = vi.fn().mockResolvedValue(undefined); + const result = await queue.flush(executor); + + expect(result.flushed).toBe(2); + expect(result.failed).toBe(0); + expect(queue.length()).toBe(0); + expect(executor).toHaveBeenCalledTimes(2); + }); + + it('should retry failed items on next flush', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + + const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); + const result = await queue.flush(failingExecutor); + + expect(result.flushed).toBe(0); + expect(result.failed).toBe(1); + expect(queue.length()).toBe(1); + }); + + it('should drop items after max retries', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + + const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); + + // Retry 1 + await queue.flush(failingExecutor); + expect(queue.length()).toBe(1); + + // Retry 2 — should be dropped + await queue.flush(failingExecutor); + expect(queue.length()).toBe(0); + }); + + it('should cap queue at maxQueueSize', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 }); + + for (let i = 0; i < 5; i++) { + queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} }); + } + + expect(queue.length()).toBe(3); + }); + + it('should persist to storage', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } }); + + const stored = JSON.parse(storage._store['test-q']); + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('1'); + }); + + it('should clear the queue', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} }); + expect(queue.length()).toBe(2); + + queue.clear(); + expect(queue.length()).toBe(0); + }); + + it('should return empty result when flushing empty queue', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + const executor = vi.fn(); + const result = await queue.flush(executor); + + expect(result.flushed).toBe(0); + expect(result.failed).toBe(0); + expect(executor).not.toHaveBeenCalled(); + }); +}); diff --git a/vendor/bytelyst/offline-queue/src/index.ts b/vendor/bytelyst/offline-queue/src/index.ts new file mode 100644 index 0000000..d11a1d0 --- /dev/null +++ b/vendor/bytelyst/offline-queue/src/index.ts @@ -0,0 +1,166 @@ +/** + * Persistent offline retry queue for browser and React Native. + * + * When an API call fails (offline, timeout, etc.), the operation is + * queued in configurable storage and retried on the next flush. + * + * No Node.js, React, or React Native dependencies. + * + * @example + * ```ts + * import { createOfflineQueue } from '@bytelyst/offline-queue'; + * + * const queue = createOfflineQueue({ + * storageKey: 'nomgap-offline-queue', + * storage: mmkvStorage, // or localStorage + * }); + * + * // On API failure: + * queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } }); + * + * // On app foreground / auth success: + * const result = await queue.flush(async (action, path, payload) => { + * await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload); + * }); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export interface QueueStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface OfflineQueueConfig { + /** Storage key for persisting the queue. */ + storageKey: string; + + /** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */ + storage: QueueStorage; + + /** Maximum retry attempts per item. Default: 5. */ + maxRetries?: number; + + /** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */ + maxQueueSize?: number; +} + +export interface QueueItem { + id: string; + action: string; + path: string; + payload: Record; + enqueuedAt: number; + retryCount: number; +} + +export interface FlushResult { + flushed: number; + failed: number; +} + +export interface OfflineQueue { + /** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */ + enqueue(item: { + id: string; + action: string; + path: string; + payload: Record; + }): void; + + /** Flush the queue — retry all pending items via the provided executor. */ + flush( + executor: (action: string, path: string, payload: Record) => Promise + ): Promise; + + /** Get current queue length. */ + length(): number; + + /** Clear the entire queue. */ + clear(): void; +} + +// ── Factory ────────────────────────────────────────────────── + +export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue { + const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config; + + function loadQueue(): QueueItem[] { + try { + const raw = storage.getItem(storageKey); + if (!raw) return []; + return JSON.parse(raw) as QueueItem[]; + } catch { + return []; + } + } + + function saveQueue(queue: QueueItem[]): void { + try { + storage.setItem(storageKey, JSON.stringify(queue)); + } catch { + // Storage unavailable + } + } + + function enqueue(item: { + id: string; + action: string; + path: string; + payload: Record; + }): void { + const queue = loadQueue(); + + // Replace existing entry for same entity + action + const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action)); + + // Cap queue size + if (filtered.length >= maxQueueSize) { + filtered.shift(); + } + + filtered.push({ + ...item, + enqueuedAt: Date.now(), + retryCount: 0, + }); + + saveQueue(filtered); + } + + async function flush( + executor: (action: string, path: string, payload: Record) => Promise + ): Promise { + const queue = loadQueue(); + if (queue.length === 0) return { flushed: 0, failed: 0 }; + + let flushed = 0; + const remaining: QueueItem[] = []; + + for (const item of queue) { + try { + await executor(item.action, item.path, item.payload); + flushed++; + } catch { + if (item.retryCount + 1 < maxRetries) { + remaining.push({ ...item, retryCount: item.retryCount + 1 }); + } + // else: silently drop — too many retries + } + } + + saveQueue(remaining); + return { flushed, failed: remaining.length }; + } + + function length(): number { + return loadQueue().length; + } + + function clear(): void { + saveQueue([]); + } + + return { enqueue, flush, length, clear }; +} diff --git a/vendor/bytelyst/offline-queue/tsconfig.json b/vendor/bytelyst/offline-queue/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/vendor/bytelyst/offline-queue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/ollama-client/package.json b/vendor/bytelyst/ollama-client/package.json new file mode 100644 index 0000000..a449ce0 --- /dev/null +++ b/vendor/bytelyst/ollama-client/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bytelyst/ollama-client", + "version": "0.1.6", + "description": "Shared Ollama API client — streaming chat, embeddings, model management, health checks", + "type": "module", + "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 --pool forks", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "vitest": "^3.0.0", + "typescript": "^5.7.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.test.ts b/vendor/bytelyst/ollama-client/src/client-parsers.test.ts new file mode 100644 index 0000000..bd65194 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/client-parsers.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest'; +import { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; + +function createResponse(body: string): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + return { ok: true, body: stream } as unknown as Response; +} + +describe('consumeSSEStream', () => { + it('parses data: lines and calls onData', async () => { + const response = createResponse('data: {"msg":"hello"}\ndata: {"msg":"world"}\n\n'); + const onData = vi.fn(); + const onDone = vi.fn(); + + await consumeSSEStream(response, onData, onDone); + + expect(onData).toHaveBeenCalledTimes(2); + expect(onData).toHaveBeenCalledWith({ msg: 'hello' }); + expect(onData).toHaveBeenCalledWith({ msg: 'world' }); + expect(onDone).toHaveBeenCalled(); + }); + + it('stops on [DONE] marker', async () => { + const response = createResponse('data: {"msg":"hello"}\ndata: [DONE]\ndata: {"msg":"after"}\n'); + const onData = vi.fn(); + const onDone = vi.fn(); + + await consumeSSEStream(response, onData, onDone); + + expect(onData).toHaveBeenCalledTimes(1); + expect(onDone).toHaveBeenCalled(); + }); + + it('calls onDone when response has no body', async () => { + const response = { ok: true, body: null } as unknown as Response; + const onDone = vi.fn(); + + await consumeSSEStream(response, vi.fn(), onDone); + expect(onDone).toHaveBeenCalled(); + }); + + it('skips malformed SSE data', async () => { + const response = createResponse('data: not-json\ndata: {"valid":true}\n'); + const onData = vi.fn(); + const onDone = vi.fn(); + + await consumeSSEStream(response, onData, onDone); + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith({ valid: true }); + }); +}); + +describe('consumeNdjsonStream', () => { + it('parses NDJSON lines and calls onChunk', async () => { + const response = createResponse('{"a":1}\n{"a":2}\n'); + const onChunk = vi.fn(); + + await consumeNdjsonStream(response, onChunk); + + expect(onChunk).toHaveBeenCalledTimes(2); + expect(onChunk).toHaveBeenCalledWith({ a: 1 }); + expect(onChunk).toHaveBeenCalledWith({ a: 2 }); + }); + + it('handles no body', async () => { + const response = { ok: true, body: null } as unknown as Response; + const onChunk = vi.fn(); + + await consumeNdjsonStream(response, onChunk); + expect(onChunk).not.toHaveBeenCalled(); + }); + + it('processes remaining buffer', async () => { + const response = createResponse('{"a":1}\n{"a":2}'); + const onChunk = vi.fn(); + + await consumeNdjsonStream(response, onChunk); + expect(onChunk).toHaveBeenCalledTimes(2); + }); + + it('skips malformed lines', async () => { + const response = createResponse('{"a":1}\nbad\n{"a":2}\n'); + const onChunk = vi.fn(); + + await consumeNdjsonStream(response, onChunk); + expect(onChunk).toHaveBeenCalledTimes(2); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.ts b/vendor/bytelyst/ollama-client/src/client-parsers.ts new file mode 100644 index 0000000..a86c356 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/client-parsers.ts @@ -0,0 +1,115 @@ +/** + * Browser-side stream consumers for Next.js API route clients. + * + * These functions consume a `Response` from a fetch call that returns + * streaming data (SSE or NDJSON) and invoke callbacks for each chunk. + */ + +/** + * Consume a Server-Sent Events (SSE) stream from a Response. + * + * Parses `data:` lines and invokes `onData` for each parsed JSON object. + * Stops when the stream ends or `[DONE]` is received. + * + * @param response - Fetch Response with SSE body + * @param onData - Callback for each parsed data event + * @param onDone - Callback when stream completes + */ +export async function consumeSSEStream( + response: Response, + onData: (data: Record) => void, + onDone: () => void +): Promise { + if (!response.body) { + onDone(); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('data:')) { + const payload = trimmed.slice(5).trim(); + if (payload === '[DONE]') { + onDone(); + return; + } + try { + onData(JSON.parse(payload) as Record); + } catch { + // skip malformed SSE data + } + } + } + } + } finally { + reader.releaseLock(); + } + + onDone(); +} + +/** + * Consume an NDJSON (newline-delimited JSON) stream from a Response. + * + * Parses each line as JSON and invokes `onChunk` for each parsed object. + * + * @param response - Fetch Response with NDJSON body + * @param onChunk - Callback for each parsed NDJSON line + */ +export async function consumeNdjsonStream( + response: Response, + onChunk: (chunk: T) => void +): Promise { + if (!response.body) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + onChunk(JSON.parse(trimmed) as T); + } catch { + // skip malformed lines + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + onChunk(JSON.parse(buffer.trim()) as T); + } catch { + // skip + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/vendor/bytelyst/ollama-client/src/client.test.ts b/vendor/bytelyst/ollama-client/src/client.test.ts new file mode 100644 index 0000000..accd16c --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/client.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OllamaClient } from './client.js'; + +const BASE_URL = 'http://localhost:11434'; + +function mockFetch(response: unknown, options?: { ok?: boolean; status?: number }) { + return vi.fn().mockResolvedValue({ + ok: options?.ok ?? true, + status: options?.status ?? 200, + json: () => Promise.resolve(response), + text: () => Promise.resolve(JSON.stringify(response)), + }); +} + +describe('OllamaClient', () => { + let client: OllamaClient; + + beforeEach(() => { + client = new OllamaClient({ baseUrl: BASE_URL }); + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('strips trailing slashes from baseUrl', () => { + const c = new OllamaClient({ baseUrl: 'http://localhost:11434///' }); + expect(c.baseUrl).toBe('http://localhost:11434'); + }); + }); + + describe('tags', () => { + it('returns list of models', async () => { + const models = [{ name: 'llama3', size: 1000, digest: 'abc', modified_at: '2024-01-01' }]; + globalThis.fetch = mockFetch({ models }); + + const result = await client.tags(); + expect(result).toEqual(models); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/tags`, + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }) + ); + }); + + it('returns empty array when models is null', async () => { + globalThis.fetch = mockFetch({ models: null }); + const result = await client.tags(); + expect(result).toEqual([]); + }); + }); + + describe('ps', () => { + it('returns running models', async () => { + const models = [ + { name: 'llama3', size: 1000, digest: 'abc', expires_at: '2024-01-01', size_vram: 500 }, + ]; + globalThis.fetch = mockFetch({ models }); + + const result = await client.ps(); + expect(result).toEqual(models); + }); + }); + + describe('show', () => { + it('sends POST with model name', async () => { + const showData = { modelfile: '', parameters: '', template: '', details: {} }; + globalThis.fetch = mockFetch(showData); + + const result = await client.show('llama3'); + expect(result).toEqual(showData); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/show`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'llama3' }), + }) + ); + }); + }); + + describe('pull (non-streaming)', () => { + it('pulls a model', async () => { + globalThis.fetch = mockFetch({ status: 'success' }); + + const result = await client.pull('llama3'); + expect(result).toEqual({ status: 'success' }); + }); + + it('throws on failure', async () => { + globalThis.fetch = mockFetch('Pull failed', { ok: false, status: 500 }); + await expect(client.pull('bad-model')).rejects.toThrow('Ollama pull failed (500)'); + }); + }); + + describe('load', () => { + it('sends generate with keep_alive', async () => { + globalThis.fetch = mockFetch({}); + + await client.load('llama3', '15m'); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/generate`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '15m' }), + }) + ); + }); + }); + + describe('unload', () => { + it('sends generate with keep_alive: 0', async () => { + globalThis.fetch = mockFetch({}); + + await client.unload('llama3'); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/generate`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '0' }), + }) + ); + }); + }); + + describe('delete', () => { + it('sends DELETE request', async () => { + globalThis.fetch = mockFetch({}); + + await client.delete('llama3'); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/delete`, + expect.objectContaining({ + method: 'DELETE', + body: JSON.stringify({ name: 'llama3' }), + }) + ); + }); + + it('throws on failure', async () => { + globalThis.fetch = mockFetch('model not found', { ok: false, status: 404 }); + await expect(client.delete('nope')).rejects.toThrow('Ollama /api/delete failed (404)'); + }); + }); + + describe('version', () => { + it('returns version string', async () => { + globalThis.fetch = mockFetch({ version: '0.5.4' }); + + const result = await client.version(); + expect(result).toBe('0.5.4'); + }); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/client.ts b/vendor/bytelyst/ollama-client/src/client.ts new file mode 100644 index 0000000..d354948 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/client.ts @@ -0,0 +1,145 @@ +import type { + OllamaClientOptions, + OllamaModel, + OllamaRunningModel, + OllamaShowResponse, + OllamaPullProgress, + OllamaVersionResponse, +} from './types.js'; +import { parseNdjsonStream } from './ndjson.js'; + +/** + * Ollama API client for model management operations. + * + * Provides typed methods for all non-streaming Ollama endpoints: + * tags, ps, show, pull, load, unload, delete, version. + */ +export class OllamaClient { + readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor(options: OllamaClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.timeoutMs = options.timeoutMs ?? 30_000; + } + + private async fetchJson(path: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + signal: init?.signal ?? controller.signal, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama ${path} failed (${res.status}): ${text.slice(0, 200)}`); + } + return (await res.json()) as T; + } finally { + clearTimeout(timeout); + } + } + + /** List all locally available models (GET /api/tags). */ + async tags(): Promise { + const data = await this.fetchJson<{ models: OllamaModel[] }>('/api/tags'); + return data.models ?? []; + } + + /** List currently running/loaded models (GET /api/ps). */ + async ps(): Promise { + const data = await this.fetchJson<{ models: OllamaRunningModel[] }>('/api/ps'); + return data.models ?? []; + } + + /** Show model details (POST /api/show). */ + async show(model: string): Promise { + return this.fetchJson('/api/show', { + method: 'POST', + body: JSON.stringify({ name: model }), + }); + } + + /** + * Pull a model from the Ollama registry (POST /api/pull). + * + * When `stream: false`, waits for the full download to complete. + * When `stream: true`, returns an async generator of progress chunks. + */ + async pull(model: string, stream?: false): Promise<{ status: string }>; + async pull(model: string, stream: true): Promise>; + async pull( + model: string, + stream: boolean = false + ): Promise<{ status: string } | AsyncGenerator> { + if (!stream) { + // Model pulls can download GBs — use 10 minute timeout instead of the default + const pullTimeoutMs = Math.max(this.timeoutMs, 600_000); + const res = await fetch(`${this.baseUrl}/api/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: model, stream: false }), + signal: AbortSignal.timeout(pullTimeoutMs), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); + } + return (await res.json()) as { status: string }; + } + + // Streaming pull — return async generator (no timeout, consumer controls lifetime) + const res = await fetch(`${this.baseUrl}/api/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: model, stream: true }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); + } + if (!res.body) throw new Error('No response body from Ollama pull'); + + return parseNdjsonStream(res.body); + } + + /** + * Load a model into memory (POST /api/generate with empty prompt + keep_alive). + * + * @param model - Model name + * @param keepAlive - How long to keep the model loaded (default: '10m') + */ + async load(model: string, keepAlive: string = '10m'): Promise { + await this.fetchJson('/api/generate', { + method: 'POST', + body: JSON.stringify({ model, prompt: '', keep_alive: keepAlive }), + }); + } + + /** + * Unload a model from memory (POST /api/generate with keep_alive: '0'). + */ + async unload(model: string): Promise { + await this.fetchJson('/api/generate', { + method: 'POST', + body: JSON.stringify({ model, prompt: '', keep_alive: '0' }), + }); + } + + /** Delete a model (DELETE /api/delete). */ + async delete(model: string): Promise { + await this.fetchJson('/api/delete', { + method: 'DELETE', + body: JSON.stringify({ name: model }), + }); + } + + /** Get Ollama server version (GET /api/version). */ + async version(): Promise { + const data = await this.fetchJson('/api/version'); + return data.version; + } +} diff --git a/vendor/bytelyst/ollama-client/src/config.test.ts b/vendor/bytelyst/ollama-client/src/config.test.ts new file mode 100644 index 0000000..fe2fb4a --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/config.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { resolveOllamaUrl } from './config.js'; + +describe('resolveOllamaUrl', () => { + it('returns default localhost when no env vars set', () => { + expect(resolveOllamaUrl({})).toBe('http://localhost:11434'); + }); + + it('uses OLLAMA_URL when set', () => { + expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434' })).toBe('http://myhost:11434'); + }); + + it('uses OLLAMA_HOST when set', () => { + expect(resolveOllamaUrl({ OLLAMA_HOST: 'http://otherhost:11434' })).toBe( + 'http://otherhost:11434' + ); + }); + + it('prefers OLLAMA_URL over OLLAMA_HOST', () => { + expect( + resolveOllamaUrl({ + OLLAMA_URL: 'http://primary:11434', + OLLAMA_HOST: 'http://secondary:11434', + }) + ).toBe('http://primary:11434'); + }); + + it('normalizes URL without scheme', () => { + expect(resolveOllamaUrl({ OLLAMA_URL: 'myhost:11434' })).toBe('http://myhost:11434'); + }); + + it('strips trailing slashes', () => { + expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434///' })).toBe('http://myhost:11434'); + }); + + it('trims whitespace', () => { + expect(resolveOllamaUrl({ OLLAMA_URL: ' http://myhost:11434 ' })).toBe('http://myhost:11434'); + }); + + it('preserves https scheme', () => { + expect(resolveOllamaUrl({ OLLAMA_URL: 'https://secure:11434' })).toBe('https://secure:11434'); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/config.ts b/vendor/bytelyst/ollama-client/src/config.ts new file mode 100644 index 0000000..a34db87 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/config.ts @@ -0,0 +1,47 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; + +function normalizeUrl(input: string): string { + const trimmed = input.trim().replace(/\/+$/, ''); + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + return `http://${trimmed}`; +} + +function detectWslGatewayOllamaUrl(): string | null { + try { + if (process.platform !== 'linux') return null; + const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); + if (!version.includes('microsoft')) return null; + + const gw = execSync("ip route show default | awk '{print $3}' | head -1", { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + if (!gw) return null; + return `http://${gw}:11434`; + } catch { + return null; + } +} + +/** + * Resolve the Ollama base URL from environment variables with WSL2 fallback. + * + * Priority: + * 1. `OLLAMA_URL` or `OLLAMA_HOST` env var (explicit config) + * 2. WSL2 gateway IP (Windows-hosted Ollama detected via /proc/version) + * 3. `http://localhost:11434` (default) + * + * @param env - Environment variables object (defaults to `process.env`) + */ +export function resolveOllamaUrl(env: Record = process.env): string { + const explicit = env.OLLAMA_URL || env.OLLAMA_HOST; + if (explicit) return normalizeUrl(explicit); + + const inferred = detectWslGatewayOllamaUrl(); + if (inferred) return inferred; + + return 'http://localhost:11434'; +} diff --git a/vendor/bytelyst/ollama-client/src/embed.test.ts b/vendor/bytelyst/ollama-client/src/embed.test.ts new file mode 100644 index 0000000..48ffe94 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/embed.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getEmbedding, getEmbeddingVector } from './embed.js'; + +const BASE_URL = 'http://localhost:11434'; + +describe('getEmbedding', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns embedding response', async () => { + const response = { model: 'nomic-embed-text', embeddings: [[0.1, 0.2, 0.3]] }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(response), + }); + + const result = await getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }); + expect(result).toEqual(response); + expect(globalThis.fetch).toHaveBeenCalledWith( + `${BASE_URL}/api/embed`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'nomic-embed-text', input: 'hello' }), + }) + ); + }); + + it('throws on error response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('internal error'), + }); + + await expect( + getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }) + ).rejects.toThrow('Ollama embed failed (500)'); + }); +}); + +describe('getEmbeddingVector', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns first embedding vector', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [[0.1, 0.2]] }), + }); + + const result = await getEmbeddingVector(BASE_URL, 'hello'); + expect(result).toEqual([0.1, 0.2]); + }); + + it('returns empty array when no embeddings', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [] }), + }); + + const result = await getEmbeddingVector(BASE_URL, 'hello'); + expect(result).toEqual([]); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/embed.ts b/vendor/bytelyst/ollama-client/src/embed.ts new file mode 100644 index 0000000..7578c12 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/embed.ts @@ -0,0 +1,47 @@ +import type { OllamaEmbedOptions, OllamaEmbeddingResponse } from './types.js'; + +/** + * Get embeddings for text using an Ollama embedding model. + * + * @param baseUrl - Ollama server base URL + * @param options - Embedding options (model, input text) + * @returns Array of embedding vectors (one per input string) + */ +export async function getEmbedding( + baseUrl: string, + options: OllamaEmbedOptions +): Promise { + const res = await fetch(`${baseUrl}/api/embed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model, + input: options.input, + ...(options.options && { options: options.options }), + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama embed failed (${res.status}): ${text.slice(0, 200)}`); + } + + return (await res.json()) as OllamaEmbeddingResponse; +} + +/** + * Convenience: get a single embedding vector for a text string. + * + * @param baseUrl - Ollama server base URL + * @param text - Text to embed + * @param model - Embedding model name (default: 'nomic-embed-text') + * @returns Single embedding vector + */ +export async function getEmbeddingVector( + baseUrl: string, + text: string, + model: string = 'nomic-embed-text' +): Promise { + const response = await getEmbedding(baseUrl, { model, input: text }); + return response.embeddings?.[0] ?? []; +} diff --git a/vendor/bytelyst/ollama-client/src/format.test.ts b/vendor/bytelyst/ollama-client/src/format.test.ts new file mode 100644 index 0000000..3011147 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/format.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; + +describe('formatBytes', () => { + it('formats 0 bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('formats bytes', () => { + expect(formatBytes(512)).toBe('512 B'); + }); + + it('formats kilobytes', () => { + expect(formatBytes(1536)).toBe('1.5 KB'); + }); + + it('formats megabytes', () => { + expect(formatBytes(5242880)).toBe('5 MB'); + }); + + it('formats gigabytes', () => { + expect(formatBytes(4294967296)).toBe('4 GB'); + }); + + it('returns 0 B for negative input', () => { + expect(formatBytes(-100)).toBe('0 B'); + }); +}); + +describe('estimateTokens', () => { + it('returns 0 for empty string', () => { + expect(estimateTokens('')).toBe(0); + }); + + it('returns 0 for whitespace-only string', () => { + expect(estimateTokens(' ')).toBe(0); + }); + + it('estimates tokens for a sentence', () => { + const result = estimateTokens('Hello world this is a test'); + expect(result).toBeGreaterThan(0); + // 6 words × 1.3 = 7.8, ceil = 8 + expect(result).toBe(8); + }); +}); + +describe('getModelContextWindow', () => { + it('returns 128k for models with 128k in name', () => { + expect(getModelContextWindow('llama3-128k')).toBe(128_000); + }); + + it('returns 32k for models with 32k in name', () => { + expect(getModelContextWindow('gpt4-32k')).toBe(32_000); + }); + + it('returns 4096 for models without size marker', () => { + expect(getModelContextWindow('llama3:latest')).toBe(4_096); + }); +}); + +describe('formatUptime', () => { + it('formats minutes only', () => { + expect(formatUptime(120)).toBe('2m'); + }); + + it('formats hours and minutes', () => { + expect(formatUptime(3661)).toBe('1h 1m'); + }); + + it('formats days, hours, and minutes', () => { + expect(formatUptime(90061)).toBe('1d 1h 1m'); + }); + + it('formats zero', () => { + expect(formatUptime(0)).toBe('0m'); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/format.ts b/vendor/bytelyst/ollama-client/src/format.ts new file mode 100644 index 0000000..c252ed0 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/format.ts @@ -0,0 +1,56 @@ +/** + * Format a byte count into a human-readable string. + * + * @example formatBytes(1536) // '1.5 KB' + * @example formatBytes(0) // '0 B' + */ +export function formatBytes(bytes: number): string { + if (bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/** + * Approximate token count for a text string. + * + * Uses a word-count × 1.3 heuristic (typical for English text with LLM tokenizers). + */ +export function estimateTokens(text: string): number { + const trimmed = text.trim(); + if (!trimmed) return 0; + return Math.ceil(trimmed.split(/\s+/).length * 1.3); +} + +/** + * Best-effort model context window lookup based on model name. + * + * Checks for common context window markers in the model name string. + * Falls back to 4096 if no marker is found. + */ +export function getModelContextWindow(modelName: string): number { + const n = modelName.toLowerCase(); + if (n.includes('128k')) return 128_000; + if (n.includes('64k')) return 64_000; + if (n.includes('32k')) return 32_000; + if (n.includes('16k')) return 16_000; + if (n.includes('8k')) return 8_000; + return 4_096; +} + +/** + * Format a duration in seconds to a human-readable uptime string. + * + * @example formatUptime(90061) // '1d 1h 1m' + * @example formatUptime(3661) // '1h 1m' + * @example formatUptime(120) // '2m' + */ +export function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} diff --git a/vendor/bytelyst/ollama-client/src/health.test.ts b/vendor/bytelyst/ollama-client/src/health.test.ts new file mode 100644 index 0000000..c1b7161 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/health.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { checkHealth, checkHealthDetailed } from './health.js'; + +const BASE_URL = 'http://localhost:11434'; + +describe('checkHealth', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true when server is healthy', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + expect(await checkHealth(BASE_URL)).toBe(true); + }); + + it('returns false when server returns non-ok', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }); + expect(await checkHealth(BASE_URL)).toBe(false); + }); + + it('returns false when fetch throws', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + expect(await checkHealth(BASE_URL)).toBe(false); + }); +}); + +describe('checkHealthDetailed', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns online + version when server is healthy', async () => { + globalThis.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('/api/tags')) return Promise.resolve({ ok: true }); + if (url.includes('/api/version')) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ version: '0.5.4' }), + }); + return Promise.reject(new Error('unexpected')); + }); + + const result = await checkHealthDetailed(BASE_URL); + expect(result).toEqual({ online: true, version: '0.5.4', url: BASE_URL }); + }); + + it('returns offline when server is unreachable', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await checkHealthDetailed(BASE_URL); + expect(result).toEqual({ online: false, version: null, url: BASE_URL }); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/health.ts b/vendor/bytelyst/ollama-client/src/health.ts new file mode 100644 index 0000000..da3410b --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/health.ts @@ -0,0 +1,44 @@ +/** + * Check if the Ollama server is reachable. + * + * @param baseUrl - Ollama server base URL + * @param timeoutMs - Timeout in milliseconds (default: 3000) + * @returns `true` if the server responds to GET /api/tags within the timeout + */ +export async function checkHealth(baseUrl: string, timeoutMs: number = 3000): Promise { + try { + const res = await fetch(`${baseUrl}/api/tags`, { + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok; + } catch { + return false; + } +} + +/** + * Check health and return structured result with version info. + */ +export async function checkHealthDetailed( + baseUrl: string, + timeoutMs: number = 3000 +): Promise<{ online: boolean; version: string | null; url: string }> { + try { + const [tagsRes, versionRes] = await Promise.allSettled([ + fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) }), + fetch(`${baseUrl}/api/version`, { signal: AbortSignal.timeout(timeoutMs) }), + ]); + + const online = tagsRes.status === 'fulfilled' && tagsRes.value.ok; + let version: string | null = null; + + if (versionRes.status === 'fulfilled' && versionRes.value.ok) { + const data = (await versionRes.value.json()) as { version?: string }; + version = data.version ?? null; + } + + return { online, version, url: baseUrl }; + } catch { + return { online: false, version: null, url: baseUrl }; + } +} diff --git a/vendor/bytelyst/ollama-client/src/index.ts b/vendor/bytelyst/ollama-client/src/index.ts new file mode 100644 index 0000000..1393e4d --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/index.ts @@ -0,0 +1,41 @@ +// Types +export type { + OllamaModel, + OllamaModelDetails, + OllamaRunningModel, + OllamaChatMessage, + OllamaStreamChunk, + OllamaGenerateChunk, + OllamaEmbeddingResponse, + OllamaShowResponse, + OllamaPullProgress, + OllamaVersionResponse, + OllamaClientOptions, + OllamaChatOptions, + OllamaGenerateOptions, + OllamaEmbedOptions, +} from './types.js'; + +// Config +export { resolveOllamaUrl } from './config.js'; + +// Client +export { OllamaClient } from './client.js'; + +// Streaming +export { streamChat, streamGenerate } from './stream.js'; + +// Embeddings +export { getEmbedding, getEmbeddingVector } from './embed.js'; + +// Health +export { checkHealth, checkHealthDetailed } from './health.js'; + +// NDJSON parser +export { parseNdjsonStream } from './ndjson.js'; + +// Client-side stream consumers +export { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; + +// Format utilities +export { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; diff --git a/vendor/bytelyst/ollama-client/src/ndjson.test.ts b/vendor/bytelyst/ollama-client/src/ndjson.test.ts new file mode 100644 index 0000000..41be1d4 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/ndjson.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { parseNdjsonStream } from './ndjson.js'; + +function createReadableStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index])); + index++; + } else { + controller.close(); + } + }, + }); +} + +describe('parseNdjsonStream', () => { + it('parses complete NDJSON lines', async () => { + const stream = createReadableStream(['{"a":1}\n{"a":2}\n{"a":3}\n']); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); + }); + + it('handles partial lines split across chunks', async () => { + const stream = createReadableStream(['{"a":', '1}\n{"a":2}\n']); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('skips empty lines', async () => { + const stream = createReadableStream(['{"a":1}\n\n\n{"a":2}\n']); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('skips malformed JSON lines', async () => { + const stream = createReadableStream(['{"a":1}\nnot json\n{"a":2}\n']); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('processes remaining buffer after stream ends', async () => { + const stream = createReadableStream(['{"a":1}\n{"a":2}']); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('handles empty stream', async () => { + const stream = createReadableStream([]); + const results: unknown[] = []; + for await (const chunk of parseNdjsonStream(stream)) { + results.push(chunk); + } + expect(results).toEqual([]); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/ndjson.ts b/vendor/bytelyst/ollama-client/src/ndjson.ts new file mode 100644 index 0000000..7642649 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/ndjson.ts @@ -0,0 +1,45 @@ +/** + * Parse an NDJSON (newline-delimited JSON) stream into an async generator. + * + * Works with both Node.js `ReadableStream` and browser `ReadableStream`. + * Handles partial lines across chunk boundaries gracefully. + */ +export async function* parseNdjsonStream( + body: ReadableStream +): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + yield JSON.parse(trimmed) as T; + } catch { + // skip malformed lines + } + } + } + + // Process remaining buffer after stream ends + if (buffer.trim()) { + try { + yield JSON.parse(buffer.trim()) as T; + } catch { + // skip + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/vendor/bytelyst/ollama-client/src/stream.test.ts b/vendor/bytelyst/ollama-client/src/stream.test.ts new file mode 100644 index 0000000..905d9f1 --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/stream.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { streamChat, streamGenerate } from './stream.js'; + +const BASE_URL = 'http://localhost:11434'; + +function createNdjsonResponse(chunks: object[]): Response { + const encoder = new TextEncoder(); + const ndjson = chunks.map(c => JSON.stringify(c)).join('\n') + '\n'; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(ndjson)); + controller.close(); + }, + }); + return { ok: true, status: 200, body: stream } as unknown as Response; +} + +describe('streamChat', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('yields chat stream chunks', async () => { + const chunks = [ + { model: 'llama3', message: { role: 'assistant', content: 'Hello' }, done: false }, + { model: 'llama3', message: { role: 'assistant', content: ' world' }, done: false }, + { model: 'llama3', message: { role: 'assistant', content: '' }, done: true, eval_count: 10 }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); + + const results = []; + for await (const chunk of streamChat(BASE_URL, { + model: 'llama3', + messages: [{ role: 'user', content: 'Hi' }], + })) { + results.push(chunk); + } + + expect(results).toHaveLength(3); + expect(results[0].message.content).toBe('Hello'); + expect(results[2].done).toBe(true); + expect(results[2].eval_count).toBe(10); + }); + + it('throws on non-ok response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('internal error'), + }); + + const gen = streamChat(BASE_URL, { + model: 'llama3', + messages: [{ role: 'user', content: 'Hi' }], + }); + await expect(gen.next()).rejects.toThrow('Ollama chat failed (500)'); + }); + + it('throws when response has no body', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + body: null, + }); + + const gen = streamChat(BASE_URL, { + model: 'llama3', + messages: [{ role: 'user', content: 'Hi' }], + }); + await expect(gen.next()).rejects.toThrow('No response body'); + }); +}); + +describe('streamGenerate', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('yields generate stream chunks', async () => { + const chunks = [ + { model: 'llama3', response: 'Hello', done: false }, + { model: 'llama3', response: ' world', done: false }, + { model: 'llama3', response: '', done: true, eval_count: 5 }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); + + const results = []; + for await (const chunk of streamGenerate(BASE_URL, { + model: 'llama3', + prompt: 'Say hello', + })) { + results.push(chunk); + } + + expect(results).toHaveLength(3); + expect(results[0].response).toBe('Hello'); + expect(results[2].done).toBe(true); + }); +}); diff --git a/vendor/bytelyst/ollama-client/src/stream.ts b/vendor/bytelyst/ollama-client/src/stream.ts new file mode 100644 index 0000000..5851d4a --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/stream.ts @@ -0,0 +1,85 @@ +import type { + OllamaChatOptions, + OllamaGenerateOptions, + OllamaStreamChunk, + OllamaGenerateChunk, +} from './types.js'; +import { parseNdjsonStream } from './ndjson.js'; + +/** + * Stream a chat completion from Ollama. + * + * Yields NDJSON chunks from `POST /api/chat` as an async generator. + * + * @param baseUrl - Ollama server base URL + * @param options - Chat options (model, messages, signal, etc.) + */ +export async function* streamChat( + baseUrl: string, + options: OllamaChatOptions +): AsyncGenerator { + const { model, messages, signal, ...rest } = options; + + const body: Record = { model, messages, stream: true }; + if (rest.options) body.options = rest.options; + if (rest.format) body.format = rest.format; + if (rest.keep_alive) body.keep_alive = rest.keep_alive; + + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama chat failed (${res.status}): ${text.slice(0, 200)}`); + } + + if (!res.body) { + throw new Error('No response body from Ollama'); + } + + yield* parseNdjsonStream(res.body); +} + +/** + * Stream a text generation from Ollama. + * + * Yields NDJSON chunks from `POST /api/generate` as an async generator. + * + * @param baseUrl - Ollama server base URL + * @param options - Generate options (model, prompt, signal, etc.) + */ +export async function* streamGenerate( + baseUrl: string, + options: OllamaGenerateOptions +): AsyncGenerator { + const { model, prompt, signal, ...rest } = options; + + const body: Record = { model, prompt, stream: true }; + if (rest.system) body.system = rest.system; + if (rest.options) body.options = rest.options; + if (rest.format) body.format = rest.format; + if (rest.keep_alive) body.keep_alive = rest.keep_alive; + if (rest.context) body.context = rest.context; + + const res = await fetch(`${baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Ollama generate failed (${res.status}): ${text.slice(0, 200)}`); + } + + if (!res.body) { + throw new Error('No response body from Ollama'); + } + + yield* parseNdjsonStream(res.body); +} diff --git a/vendor/bytelyst/ollama-client/src/types.ts b/vendor/bytelyst/ollama-client/src/types.ts new file mode 100644 index 0000000..410d89d --- /dev/null +++ b/vendor/bytelyst/ollama-client/src/types.ts @@ -0,0 +1,133 @@ +// --- Model types --- + +export interface OllamaModelDetails { + family?: string; + families?: string[]; + format?: string; + parameter_size?: string; + quantization_level?: string; +} + +export interface OllamaModel { + name: string; + model?: string; + size: number; + digest: string; + modified_at: string; + details?: OllamaModelDetails; +} + +export interface OllamaRunningModel { + name: string; + model?: string; + size: number; + digest: string; + expires_at: string; + size_vram: number; + details?: OllamaModelDetails; +} + +// --- Chat types --- + +export interface OllamaChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + images?: string[]; +} + +export interface OllamaStreamChunk { + model: string; + message: { role: string; content: string }; + done: boolean; + total_duration?: number; + eval_count?: number; + eval_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + load_duration?: number; +} + +// --- Generate types --- + +export interface OllamaGenerateChunk { + model: string; + response: string; + done: boolean; + total_duration?: number; + eval_count?: number; + eval_duration?: number; + prompt_eval_count?: number; + prompt_eval_duration?: number; + load_duration?: number; + context?: number[]; +} + +// --- Embedding types --- + +export interface OllamaEmbeddingResponse { + model: string; + embeddings: number[][]; + total_duration?: number; + load_duration?: number; + prompt_eval_count?: number; +} + +// --- Show types --- + +export interface OllamaShowResponse { + modelfile: string; + parameters: string; + template: string; + details: OllamaModelDetails; + model_info?: Record; +} + +// --- Pull progress types --- + +export interface OllamaPullProgress { + status: string; + digest?: string; + total?: number; + completed?: number; +} + +// --- Version types --- + +export interface OllamaVersionResponse { + version: string; +} + +// --- Client options --- + +export interface OllamaClientOptions { + /** Base URL of the Ollama server (e.g. http://localhost:11434) */ + baseUrl: string; + /** Default request timeout in milliseconds (default: 30000) */ + timeoutMs?: number; +} + +export interface OllamaChatOptions { + model: string; + messages: OllamaChatMessage[]; + signal?: AbortSignal; + options?: Record; + format?: 'json' | string; + keep_alive?: string; +} + +export interface OllamaGenerateOptions { + model: string; + prompt: string; + signal?: AbortSignal; + system?: string; + options?: Record; + format?: 'json' | string; + keep_alive?: string; + context?: number[]; +} + +export interface OllamaEmbedOptions { + model: string; + input: string | string[]; + options?: Record; +} diff --git a/vendor/bytelyst/ollama-client/tsconfig.json b/vendor/bytelyst/ollama-client/tsconfig.json new file mode 100644 index 0000000..b74148d --- /dev/null +++ b/vendor/bytelyst/ollama-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/ollama-client/vitest.config.ts b/vendor/bytelyst/ollama-client/vitest.config.ts new file mode 100644 index 0000000..811c18a --- /dev/null +++ b/vendor/bytelyst/ollama-client/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + passWithNoTests: true, + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/org-client/package.json b/vendor/bytelyst/org-client/package.json new file mode 100644 index 0000000..a12b5f1 --- /dev/null +++ b/vendor/bytelyst/org-client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/org-client", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/org-client/src/client.test.ts b/vendor/bytelyst/org-client/src/client.test.ts new file mode 100644 index 0000000..4dbc8cc --- /dev/null +++ b/vendor/bytelyst/org-client/src/client.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createOrgClient } from './client.js'; +import type { OrganizationDoc, WorkspaceDoc, MembershipDoc, LicenseDoc } from './types.js'; + +const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'admin-token', +}; + +function mockOrg(overrides?: Partial): OrganizationDoc { + return { + id: 'org_1', + productId: 'testapp', + name: 'Test Org', + slug: 'test-org', + status: 'active', + ownerUserId: 'user-1', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockWorkspace(overrides?: Partial): WorkspaceDoc { + return { + id: 'ws_1', + orgId: 'org_1', + productId: 'testapp', + name: 'Engineering', + slug: 'engineering', + status: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockMembership(overrides?: Partial): MembershipDoc { + return { + id: 'mbr_org1_user1_org', + orgId: 'org_1', + productId: 'testapp', + scope: 'org', + userId: 'user-1', + role: 'admin', + status: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockLicense(overrides?: Partial): LicenseDoc { + return { + id: 'lic_1', + productId: 'testapp', + key: 'LYSNR-XXXX-YYYY-ZZZZ', + userId: 'user-1', + plan: 'pro', + status: 'active', + activatedAt: '2026-01-01T00:00:00Z', + expiresAt: null, + deviceIds: ['device-1'], + maxDevices: 3, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('createOrgClient', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should list orgs', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([mockOrg()]), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.listOrgs(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Test Org'); + }); + + it('should create an org', async () => { + const org = mockOrg(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(org), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.createOrg({ name: 'Test Org', slug: 'test-org' }); + expect(result.id).toBe('org_1'); + }); + + it('should get an org by id', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockOrg()), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.getOrg('org_1'); + expect(result.slug).toBe('test-org'); + }); + + it('should list workspaces for an org', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([mockWorkspace()]), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.listWorkspaces('org_1'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Engineering'); + }); + + it('should create a workspace', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockWorkspace()), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.createWorkspace('org_1', { + name: 'Engineering', + slug: 'engineering', + }); + expect(result.orgId).toBe('org_1'); + }); + + it('should list memberships', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([mockMembership()]), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.listMemberships('org_1'); + expect(result).toHaveLength(1); + expect(result[0].role).toBe('admin'); + }); + + it('should add a member', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockMembership()), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.addMember('org_1', { userId: 'user-1', role: 'admin' }); + expect(result.userId).toBe('user-1'); + }); + + it('should generate a license', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLicense()), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.generateLicense({ userId: 'user-1', plan: 'pro' }); + expect(result.key).toContain('LYSNR'); + }); + + it('should activate a license', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLicense()), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.activateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }); + expect(result.status).toBe('active'); + }); + + it('should update an org', async () => { + const org = mockOrg({ name: 'Updated Org' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(org), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.updateOrg('org_1', { name: 'Updated Org' }); + expect(result.name).toBe('Updated Org'); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/orgs/org_1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + + it('should update a workspace', async () => { + const ws = mockWorkspace({ name: 'Renamed' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(ws), + }) + ); + const client = createOrgClient(baseConfig); + const result = await client.updateWorkspace('org_1', 'ws_1', { name: 'Renamed' }); + expect(result.name).toBe('Renamed'); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/orgs/org_1/workspaces/ws_1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + + it('should deactivate a license', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + }) + ); + const client = createOrgClient(baseConfig); + await expect( + client.deactivateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }) + ).resolves.toBeUndefined(); + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/licenses/deactivate', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should throw 403 for non-admin', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + }) + ); + const client = createOrgClient(baseConfig); + await expect(client.listOrgs()).rejects.toThrow('listOrgs failed: 403'); + }); + + it('should send correct headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + ); + const client = createOrgClient(baseConfig); + await client.listOrgs(); + + const fetchMock = globalThis.fetch as ReturnType; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer admin-token'); + expect(callHeaders['x-request-id']).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/org-client/src/client.ts b/vendor/bytelyst/org-client/src/client.ts new file mode 100644 index 0000000..a2135ee --- /dev/null +++ b/vendor/bytelyst/org-client/src/client.ts @@ -0,0 +1,224 @@ +/** + * Browser/React Native-safe org, workspace, membership, and license client + * for platform-service. + * + * All org routes require admin-only access (super_admin or admin JWT role). + * No Node.js dependencies — uses globalThis.fetch. + */ + +import type { + LicenseDoc, + MembershipDoc, + OrgClient, + OrgClientConfig, + OrganizationDoc, + WorkspaceDoc, +} from './types.js'; + +function generateRequestId(): string { + return typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createOrgClient(config: OrgClientConfig): OrgClient { + const { baseUrl, productId, getAccessToken } = config; + + function headers(): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': generateRequestId(), + }; + const token = getAccessToken(); + if (token) h['Authorization'] = `Bearer ${token}`; + return h; + } + + // ── Organizations ───────────────────────────────── + + async function listOrgs(query?: { status?: string; limit?: number }): Promise { + const params = new URLSearchParams(); + if (query?.status) params.set('status', query.status); + if (query?.limit) params.set('limit', String(query.limit)); + const qs = params.toString(); + const url = qs ? `${baseUrl}/orgs?${qs}` : `${baseUrl}/orgs`; + const res = await globalThis.fetch(url, { headers: headers() }); + if (!res.ok) throw new Error(`listOrgs failed: ${res.status}`); + return (await res.json()) as OrganizationDoc[]; + } + + async function createOrg(input: { + name: string; + slug: string; + ownerUserId?: string; + }): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ ...input, productId }), + }); + if (!res.ok) throw new Error(`createOrg failed: ${res.status}`); + return (await res.json()) as OrganizationDoc; + } + + async function getOrg(id: string): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { + headers: headers(), + }); + if (!res.ok) throw new Error(`getOrg failed: ${res.status}`); + return (await res.json()) as OrganizationDoc; + } + + async function updateOrg( + id: string, + updates: Partial + ): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: headers(), + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error(`updateOrg failed: ${res.status}`); + return (await res.json()) as OrganizationDoc; + } + + // ── Workspaces ──────────────────────────────────── + + async function listWorkspaces(orgId: string): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { + headers: headers(), + }); + if (!res.ok) throw new Error(`listWorkspaces failed: ${res.status}`); + return (await res.json()) as WorkspaceDoc[]; + } + + async function createWorkspace( + orgId: string, + input: { name: string; slug: string; description?: string } + ): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`createWorkspace failed: ${res.status}`); + return (await res.json()) as WorkspaceDoc; + } + + async function updateWorkspace( + orgId: string, + workspaceId: string, + updates: Partial + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(workspaceId)}`, + { + method: 'PATCH', + headers: headers(), + body: JSON.stringify(updates), + } + ); + if (!res.ok) throw new Error(`updateWorkspace failed: ${res.status}`); + return (await res.json()) as WorkspaceDoc; + } + + // ── Memberships ─────────────────────────────────── + + async function listMemberships( + orgId: string, + query?: { scope?: string; limit?: number } + ): Promise { + const params = new URLSearchParams(); + if (query?.scope) params.set('scope', query.scope); + if (query?.limit) params.set('limit', String(query.limit)); + const qs = params.toString(); + const url = qs + ? `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships?${qs}` + : `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`; + const res = await globalThis.fetch(url, { headers: headers() }); + if (!res.ok) throw new Error(`listMemberships failed: ${res.status}`); + return (await res.json()) as MembershipDoc[]; + } + + async function addMember( + orgId: string, + input: { userId: string; role?: string; scope?: string; workspaceId?: string } + ): Promise { + const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`addMember failed: ${res.status}`); + return (await res.json()) as MembershipDoc; + } + + async function updateMember( + orgId: string, + membershipId: string, + updates: { role?: string; status?: string } + ): Promise { + const res = await globalThis.fetch( + `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships/${encodeURIComponent(membershipId)}`, + { + method: 'PATCH', + headers: headers(), + body: JSON.stringify(updates), + } + ); + if (!res.ok) throw new Error(`updateMember failed: ${res.status}`); + return (await res.json()) as MembershipDoc; + } + + // ── Licenses ────────────────────────────────────── + + async function generateLicense(input: { + userId: string; + plan: string; + maxDevices?: number; + }): Promise { + const res = await globalThis.fetch(`${baseUrl}/licenses`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ ...input, productId }), + }); + if (!res.ok) throw new Error(`generateLicense failed: ${res.status}`); + return (await res.json()) as LicenseDoc; + } + + async function activateLicense(input: { key: string; deviceId: string }): Promise { + const res = await globalThis.fetch(`${baseUrl}/licenses/activate`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`activateLicense failed: ${res.status}`); + return (await res.json()) as LicenseDoc; + } + + async function deactivateLicense(input: { key: string; deviceId: string }): Promise { + const res = await globalThis.fetch(`${baseUrl}/licenses/deactivate`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(`deactivateLicense failed: ${res.status}`); + } + + return { + listOrgs, + createOrg, + getOrg, + updateOrg, + listWorkspaces, + createWorkspace, + updateWorkspace, + listMemberships, + addMember, + updateMember, + generateLicense, + activateLicense, + deactivateLicense, + }; +} diff --git a/vendor/bytelyst/org-client/src/index.ts b/vendor/bytelyst/org-client/src/index.ts new file mode 100644 index 0000000..2eaba2e --- /dev/null +++ b/vendor/bytelyst/org-client/src/index.ts @@ -0,0 +1,58 @@ +export interface OrgClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface OrgDoc { + id: string; + name: string; + slug: string; + memberCount: number; + plan: string; + metadata?: Record; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: OrgClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createOrgClient(opts: OrgClientOptions) { + const { baseUrl } = opts; + + return { + async listOrgs(): Promise { + const res = await fetch(joinUrl(baseUrl, "/organizations"), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + async getOrg(orgId: string): Promise { + const res = await fetch( + joinUrl(baseUrl, `/organizations/${encodeURIComponent(orgId)}`), + { method: "GET", headers: headers(opts) } + ); + return parseJson(res); + }, + }; +} diff --git a/vendor/bytelyst/org-client/src/types.ts b/vendor/bytelyst/org-client/src/types.ts new file mode 100644 index 0000000..4ac9a86 --- /dev/null +++ b/vendor/bytelyst/org-client/src/types.ts @@ -0,0 +1,113 @@ +/** + * Types for @bytelyst/org-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface OrganizationDoc { + id: string; + productId: string; + name: string; + slug: string; + status: 'active' | 'disabled'; + ownerUserId: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface WorkspaceDoc { + id: string; + orgId: string; + productId: string; + name: string; + slug: string; + status: 'active' | 'archived'; + description?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface MembershipDoc { + id: string; + orgId: string; + productId: string; + scope: 'org' | 'workspace'; + workspaceId?: string; + userId: string; + role: 'owner' | 'admin' | 'member' | 'viewer'; + status: 'active' | 'invited' | 'disabled'; + invitedBy?: string; + createdAt: string; + updatedAt: string; +} + +export interface LicenseDoc { + id: string; + productId: string; + key: string; + userId: string; + plan: 'free' | 'pro' | 'enterprise'; + status: 'active' | 'revoked' | 'expired'; + activatedAt: string | null; + expiresAt: string | null; + deviceIds: string[]; + maxDevices: number; + createdAt: string; + updatedAt: string; +} + +export interface OrgClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header on every request. */ + productId: string; + + /** Returns a JWT access token (admin role required), or null. */ + getAccessToken: () => string | null; +} + +export interface OrgClient { + // Organizations + listOrgs(query?: { status?: string; limit?: number }): Promise; + createOrg(input: { name: string; slug: string; ownerUserId?: string }): Promise; + getOrg(id: string): Promise; + updateOrg(id: string, updates: Partial): Promise; + + // Workspaces + listWorkspaces(orgId: string): Promise; + createWorkspace( + orgId: string, + input: { name: string; slug: string; description?: string } + ): Promise; + updateWorkspace( + orgId: string, + workspaceId: string, + updates: Partial + ): Promise; + + // Memberships + listMemberships( + orgId: string, + query?: { scope?: string; limit?: number } + ): Promise; + addMember( + orgId: string, + input: { userId: string; role?: string; scope?: string; workspaceId?: string } + ): Promise; + updateMember( + orgId: string, + membershipId: string, + updates: { role?: string; status?: string } + ): Promise; + + // Licenses + generateLicense(input: { + userId: string; + plan: string; + maxDevices?: number; + }): Promise; + activateLicense(input: { key: string; deviceId: string }): Promise; + deactivateLicense(input: { key: string; deviceId: string }): Promise; +} diff --git a/vendor/bytelyst/org-client/tsconfig.json b/vendor/bytelyst/org-client/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/org-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/palace/package.json b/vendor/bytelyst/palace/package.json new file mode 100644 index 0000000..813a38b --- /dev/null +++ b/vendor/bytelyst/palace/package.json @@ -0,0 +1,31 @@ +{ + "name": "@bytelyst/palace", + "version": "0.1.4", + "description": "Shared MemPalace primitives — types, cosine similarity, dedup, relevance decay, extraction prompts, KG helpers, wake-up context builder", + "type": "module", + "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 --pool forks" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/palace/src/__tests__/cosine.test.ts b/vendor/bytelyst/palace/src/__tests__/cosine.test.ts new file mode 100644 index 0000000..4d8a449 --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/cosine.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { cosineSimilarity, normalizeVector, topKByCosine } from '../cosine.js'; + +describe('cosineSimilarity', () => { + it('returns 1.0 for identical vectors', () => { + expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1.0); + }); + + it('returns 0.0 for orthogonal vectors', () => { + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0); + }); + + it('returns -1.0 for opposite vectors', () => { + expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0); + }); + + it('returns 0 for empty vectors', () => { + expect(cosineSimilarity([], [])).toBe(0); + }); + + it('returns 0 for mismatched dimensions', () => { + expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0); + }); + + it('returns 0 for zero vector', () => { + expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0); + }); + + it('computes correct similarity for arbitrary vectors', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + // Known value: (4+10+18) / (sqrt(14)*sqrt(77)) ≈ 0.9746 + expect(cosineSimilarity(a, b)).toBeCloseTo(0.9746, 3); + }); +}); + +describe('normalizeVector', () => { + it('normalizes to unit length', () => { + const v = normalizeVector([3, 4]); + const magnitude = Math.sqrt(v[0] ** 2 + v[1] ** 2); + expect(magnitude).toBeCloseTo(1.0); + }); + + it('returns zero vector for zero input', () => { + expect(normalizeVector([0, 0, 0])).toEqual([0, 0, 0]); + }); + + it('preserves direction', () => { + const v = normalizeVector([2, 0, 0]); + expect(v[0]).toBeCloseTo(1); + expect(v[1]).toBeCloseTo(0); + expect(v[2]).toBeCloseTo(0); + }); +}); + +describe('topKByCosine', () => { + const items = [ + { id: 'a', emb: [1, 0, 0] }, + { id: 'b', emb: [0, 1, 0] }, + { id: 'c', emb: [0.9, 0.1, 0] }, + { id: 'd', emb: undefined as number[] | undefined }, + ]; + + it('returns top-K sorted by score', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 2); + expect(results).toHaveLength(2); + expect(results[0].item.id).toBe('a'); + expect(results[0].score).toBeCloseTo(1.0); + expect(results[1].item.id).toBe('c'); + }); + + it('skips items with missing embeddings', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 10); + expect(results).toHaveLength(3); // 'd' skipped + }); + + it('respects minScore filter', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 10, 0.5); + expect(results.every(r => r.score >= 0.5)).toBe(true); + }); + + it('returns empty array for empty items', () => { + const results = topKByCosine([1, 0, 0], [], () => undefined, 5); + expect(results).toEqual([]); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/decay.test.ts b/vendor/bytelyst/palace/src/__tests__/decay.test.ts new file mode 100644 index 0000000..dff95bd --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/decay.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { computeDecayedRelevance, boostRelevance } from '../decay.js'; + +describe('computeDecayedRelevance', () => { + it('returns original relevance for just-created memory', () => { + const now = new Date(); + expect(computeDecayedRelevance(0.8, now.toISOString(), 30, now)).toBeCloseTo(0.8); + }); + + it('halves relevance after exactly one half-life', () => { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 86_400_000); + expect(computeDecayedRelevance(1.0, thirtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.5, 2); + }); + + it('quarters relevance after two half-lives', () => { + const now = new Date(); + const sixtyDaysAgo = new Date(now.getTime() - 60 * 86_400_000); + expect(computeDecayedRelevance(1.0, sixtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.25, 2); + }); + + it('returns 0 for zero relevance', () => { + expect(computeDecayedRelevance(0, '2024-01-01')).toBe(0); + }); + + it('clamps to [0, 1]', () => { + const now = new Date(); + expect(computeDecayedRelevance(1.5, now.toISOString(), 30, now)).toBeLessThanOrEqual(1); + }); + + it('returns original relevance for zero half-life', () => { + expect(computeDecayedRelevance(0.8, '2024-01-01', 0)).toBe(0.8); + }); + + it('accepts Date objects', () => { + const now = new Date(); + expect(computeDecayedRelevance(0.8, now, 30, now)).toBeCloseTo(0.8); + }); +}); + +describe('boostRelevance', () => { + it('boosts relevance toward 1.0', () => { + expect(boostRelevance(0.5, 0.3)).toBeCloseTo(0.65); + }); + + it('does not exceed 1.0', () => { + expect(boostRelevance(0.99, 0.5)).toBeLessThanOrEqual(1.0); + }); + + it('returns 0 for zero relevance with zero boost', () => { + expect(boostRelevance(0, 0)).toBe(0); + }); + + it('applies default boost factor of 0.3', () => { + expect(boostRelevance(0.5)).toBeCloseTo(0.65); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/dedup.test.ts b/vendor/bytelyst/palace/src/__tests__/dedup.test.ts new file mode 100644 index 0000000..680cc32 --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/dedup.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { isContentDuplicate, isExactDuplicate, findClosestMatch } from '../dedup.js'; + +describe('isContentDuplicate', () => { + const baseEmbedding = [1, 0, 0]; + const similarEmbedding = [0.99, 0.1, 0]; // very similar to base + const differentEmbedding = [0, 1, 0]; // orthogonal + + it('detects near-duplicate above threshold', () => { + expect(isContentDuplicate(baseEmbedding, [similarEmbedding], 0.9)).toBe(true); + }); + + it('rejects non-duplicate below threshold', () => { + expect(isContentDuplicate(baseEmbedding, [differentEmbedding], 0.9)).toBe(false); + }); + + it('returns false for empty existing embeddings', () => { + expect(isContentDuplicate(baseEmbedding, [], 0.9)).toBe(false); + }); + + it('skips embeddings with mismatched dimensions', () => { + expect(isContentDuplicate([1, 0], [[1, 0, 0]], 0.9)).toBe(false); + }); + + it('uses default threshold of 0.90', () => { + expect(isContentDuplicate(baseEmbedding, [similarEmbedding])).toBe(true); + }); +}); + +describe('isExactDuplicate', () => { + it('detects exact match', () => { + expect(isExactDuplicate('Hello World', 'Hello World')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isExactDuplicate('Hello', 'hello')).toBe(true); + }); + + it('trims whitespace', () => { + expect(isExactDuplicate(' hello ', 'hello')).toBe(true); + }); + + it('rejects different content', () => { + expect(isExactDuplicate('Hello', 'World')).toBe(false); + }); +}); + +describe('findClosestMatch', () => { + it('finds the closest embedding', () => { + const result = findClosestMatch( + [1, 0, 0], + [ + [0, 1, 0], + [0.9, 0.1, 0], + [0, 0, 1], + ] + ); + expect(result).not.toBeNull(); + expect(result!.index).toBe(1); + expect(result!.score).toBeGreaterThan(0.9); + }); + + it('returns null for empty embeddings', () => { + expect(findClosestMatch([1, 0], [])).toBeNull(); + }); + + it('returns null when no embedding exceeds minScore', () => { + expect(findClosestMatch([1, 0, 0], [[0, 1, 0]], 0.99)).toBeNull(); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/extraction.test.ts b/vendor/bytelyst/palace/src/__tests__/extraction.test.ts new file mode 100644 index 0000000..a787dc7 --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/extraction.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { + buildExtractionPrompt, + parseExtractionResponse, + regexFallbackExtraction, +} from '../extraction.js'; + +describe('buildExtractionPrompt', () => { + it('includes hall types in the prompt', () => { + const prompt = buildExtractionPrompt('some content', { + hallTypes: ['decisions', 'events', 'discoveries'], + }); + expect(prompt).toContain('decisions, events, discoveries'); + expect(prompt).toContain('some content'); + }); + + it('includes title when provided', () => { + const prompt = buildExtractionPrompt('content', { + title: 'My Note', + hallTypes: ['decisions'], + }); + expect(prompt).toContain('Title: My Note'); + }); + + it('includes context when provided', () => { + const prompt = buildExtractionPrompt('content', { + context: 'Work brain', + hallTypes: ['decisions'], + }); + expect(prompt).toContain('Context: Work brain'); + }); + + it('omits title/context when not provided', () => { + const prompt = buildExtractionPrompt('content', { + hallTypes: ['decisions'], + }); + expect(prompt).not.toContain('Title:'); + expect(prompt).not.toContain('Context:'); + }); +}); + +describe('parseExtractionResponse', () => { + it('parses valid JSON array', () => { + const input = JSON.stringify([ + { hall: 'decisions', content: 'Use TypeScript', roomSlug: 'tech', entities: ['TypeScript'] }, + ]); + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + expect(result[0].content).toBe('Use TypeScript'); + expect(result[0].roomSlug).toBe('tech'); + expect(result[0].entities).toEqual(['TypeScript']); + }); + + it('handles JSON wrapped in code fences', () => { + const input = + '```json\n[{"hall":"events","content":"Released v2","roomSlug":"releases","entities":[]}]\n```'; + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('events'); + }); + + it('returns empty array for malformed JSON', () => { + expect(parseExtractionResponse('not json at all')).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseExtractionResponse('')).toEqual([]); + }); + + it('filters out items missing required fields', () => { + const input = JSON.stringify([{ hall: 'decisions', content: 'valid' }, { noHall: true }]); + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + }); + + it('defaults roomSlug to general', () => { + const input = JSON.stringify([{ hall: 'events', content: 'something' }]); + const result = parseExtractionResponse(input); + expect(result[0].roomSlug).toBe('general'); + }); + + it('handles room_slug snake_case variant', () => { + const input = JSON.stringify([{ hall: 'events', content: 'x', room_slug: 'my-room' }]); + const result = parseExtractionResponse(input); + expect(result[0].roomSlug).toBe('my-room'); + }); +}); + +describe('regexFallbackExtraction', () => { + it('extracts Decision: lines', () => { + const result = regexFallbackExtraction('Decision: Use Cosmos DB for storage'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + expect(result[0].content).toContain('Use Cosmos DB'); + }); + + it('extracts TODO: lines', () => { + const result = regexFallbackExtraction('TODO: Fix the auth bug'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + }); + + it('extracts Found: lines as discoveries', () => { + const result = regexFallbackExtraction('Found: New API endpoint for search'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('discoveries'); + }); + + it('extracts @mentions as entities', () => { + const result = regexFallbackExtraction('Decision: @alice proposed the new schema'); + expect(result[0].entities).toContain('alice'); + }); + + it('extracts #tags as entities', () => { + const result = regexFallbackExtraction('Found: #typescript has better types'); + expect(result[0].entities).toContain('typescript'); + }); + + it('returns empty array for plain text without patterns', () => { + expect(regexFallbackExtraction('Just some random text here')).toEqual([]); + }); + + it('handles multiple patterns in multi-line content', () => { + const content = `Decision: Adopt ESM +Found: Vitest is faster +TODO: Migrate tests`; + const result = regexFallbackExtraction(content); + expect(result).toHaveLength(3); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/halls.test.ts b/vendor/bytelyst/palace/src/__tests__/halls.test.ts new file mode 100644 index 0000000..dd07b0c --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/halls.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { HALL_PRESETS, getHallPreset, hallFromLabel, ALL_HALL_TYPES } from '../halls.js'; + +describe('HALL_PRESETS', () => { + it('notelett has 6 halls', () => { + expect(HALL_PRESETS.notelett.halls).toHaveLength(6); + expect(HALL_PRESETS.notelett.halls).toContain('insights'); + expect(HALL_PRESETS.notelett.halls).not.toContain('errors'); + }); + + it('mindlyst has 6 halls with patterns and emotions', () => { + expect(HALL_PRESETS.mindlyst.halls).toHaveLength(6); + expect(HALL_PRESETS.mindlyst.halls).toContain('patterns'); + expect(HALL_PRESETS.mindlyst.halls).toContain('emotions'); + }); + + it('coding has 6 halls with errors and advice', () => { + expect(HALL_PRESETS.coding.halls).toHaveLength(6); + expect(HALL_PRESETS.coding.halls).toContain('errors'); + expect(HALL_PRESETS.coding.halls).toContain('advice'); + }); +}); + +describe('getHallPreset', () => { + it('returns preset by name', () => { + expect(getHallPreset('notelett')?.name).toBe('NoteLett'); + }); + + it('returns undefined for unknown preset', () => { + expect(getHallPreset('nonexistent')).toBeUndefined(); + }); +}); + +describe('hallFromLabel', () => { + it('matches exact hall type', () => { + expect(hallFromLabel('decisions')).toBe('decisions'); + }); + + it('is case-insensitive', () => { + expect(hallFromLabel('DECISIONS')).toBe('decisions'); + }); + + it('matches singular form via synonym', () => { + expect(hallFromLabel('decision')).toBe('decisions'); + }); + + it('maps synonyms to halls', () => { + expect(hallFromLabel('bug')).toBe('errors'); + expect(hallFromLabel('feeling')).toBe('emotions'); + expect(hallFromLabel('tip')).toBe('advice'); + expect(hallFromLabel('trend')).toBe('patterns'); + expect(hallFromLabel('fact')).toBe('discoveries'); + }); + + it('respects allowedHalls filter', () => { + // 'errors' is not in notelett preset + expect(hallFromLabel('bug', HALL_PRESETS.notelett.halls)).toBeUndefined(); + // 'errors' is in coding preset + expect(hallFromLabel('bug', HALL_PRESETS.coding.halls)).toBe('errors'); + }); + + it('returns undefined for unrecognized label', () => { + expect(hallFromLabel('xyzzy')).toBeUndefined(); + }); + + it('trims whitespace', () => { + expect(hallFromLabel(' events ')).toBe('events'); + }); +}); + +describe('ALL_HALL_TYPES', () => { + it('has 9 total hall types', () => { + expect(ALL_HALL_TYPES).toHaveLength(9); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/kg.test.ts b/vendor/bytelyst/palace/src/__tests__/kg.test.ts new file mode 100644 index 0000000..40590f1 --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/kg.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { findContradictions, mergeTriples, isTripleCurrent } from '../kg.js'; +import type { TripleInput } from '../kg.js'; + +const now = new Date('2025-06-01T00:00:00Z'); + +describe('isTripleCurrent', () => { + it('returns true for triple with no validTo', () => { + expect(isTripleCurrent({ validTo: undefined }, now)).toBe(true); + }); + + it('returns true for triple with future validTo', () => { + expect(isTripleCurrent({ validTo: '2026-01-01T00:00:00Z' }, now)).toBe(true); + }); + + it('returns false for triple with past validTo', () => { + expect(isTripleCurrent({ validTo: '2024-01-01T00:00:00Z' }, now)).toBe(false); + }); +}); + +describe('findContradictions', () => { + it('detects contradiction (same subject+predicate, different object)', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + const result = findContradictions(existing, incoming, now); + expect(result).toHaveLength(1); + expect(result[0].existing.object).toBe('PostgreSQL'); + expect(result[0].incoming.object).toBe('Cosmos DB'); + }); + + it('does not flag same triple as contradiction', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(0); + }); + + it('ignores expired triples', () => { + const existing: TripleInput[] = [ + { + subject: 'App', + predicate: 'uses', + object: 'PostgreSQL', + validFrom: '2024-01-01', + validTo: '2024-12-31', + }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(0); + }); + + it('is case-insensitive', () => { + const existing: TripleInput[] = [ + { subject: 'app', predicate: 'Uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(1); + }); +}); + +describe('mergeTriples', () => { + it('adds non-conflicting triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'runs-on', object: 'Node.js', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.added).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + expect(result.invalidated).toHaveLength(0); + expect(result.merged).toHaveLength(2); + }); + + it('skips duplicate triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.skipped).toHaveLength(1); + expect(result.added).toHaveLength(0); + }); + + it('invalidates contradicted triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.invalidated).toHaveLength(1); + expect(result.added).toHaveLength(1); + // The invalidated triple should have validTo set + const invalidated = result.merged.find(t => t.object === 'PostgreSQL'); + expect(invalidated?.validTo).toBeDefined(); + }); +}); diff --git a/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts b/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts new file mode 100644 index 0000000..a4e58b4 --- /dev/null +++ b/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + buildWakeUpLayers, + truncateToTokenBudget, + estimateTokens, + WAKEUP_PRESETS, +} from '../wakeup.js'; + +describe('estimateTokens', () => { + it('estimates ~4 chars per token', () => { + expect(estimateTokens('abcd')).toBe(1); + expect(estimateTokens('abcdefgh')).toBe(2); + }); + + it('returns 0 for empty string', () => { + expect(estimateTokens('')).toBe(0); + }); +}); + +describe('truncateToTokenBudget', () => { + it('returns unchanged text if within budget', () => { + expect(truncateToTokenBudget('short', 100)).toBe('short'); + }); + + it('truncates long text with ellipsis', () => { + const longText = 'a'.repeat(1000); + const result = truncateToTokenBudget(longText, 10); // 40 chars max + expect(result.length).toBeLessThanOrEqual(43); // 40 + '...' + expect(result.endsWith('...')).toBe(true); + }); + + it('returns empty string for empty input', () => { + expect(truncateToTokenBudget('', 100)).toBe(''); + }); +}); + +describe('buildWakeUpLayers', () => { + const config = WAKEUP_PRESETS.notelett; // 600 total, 50/150/400 + + it('assembles all three layers', () => { + const result = buildWakeUpLayers( + 'Project: NoteLett', + 'Key fact: Uses Cosmos DB', + 'Related: User asked about search', + config + ); + expect(result.text).toContain('[Identity]'); + expect(result.text).toContain('[Critical Facts]'); + expect(result.text).toContain('[Relevant Memories]'); + expect(result.layers).toHaveLength(3); + }); + + it('handles empty L0', () => { + const result = buildWakeUpLayers('', 'facts', 'memories', config); + expect(result.layers.find(l => l.label === 'L0:identity')).toBeUndefined(); + expect(result.layers).toHaveLength(2); + }); + + it('handles all empty layers gracefully', () => { + const result = buildWakeUpLayers('', '', '', config); + expect(result.text).toBe(''); + expect(result.layers).toHaveLength(0); + expect(result.truncated).toBe(false); + }); + + it('marks truncated when content exceeds budget', () => { + const longL2 = 'x '.repeat(5000); + const result = buildWakeUpLayers('id', 'fact', longL2, config); + expect(result.truncated).toBe(true); + }); + + it('respects total budget', () => { + const result = buildWakeUpLayers( + 'identity context here', + 'critical facts here', + 'semantic memories here', + config + ); + const tokens = estimateTokens(result.text); + expect(tokens).toBeLessThanOrEqual(config.totalBudget + 10); // small margin for headers + }); +}); + +describe('WAKEUP_PRESETS', () => { + it('has notelett preset with 600 budget', () => { + expect(WAKEUP_PRESETS.notelett.totalBudget).toBe(600); + }); + + it('has mindlyst preset with 800 budget', () => { + expect(WAKEUP_PRESETS.mindlyst.totalBudget).toBe(800); + }); + + it('has coding preset with 800 budget', () => { + expect(WAKEUP_PRESETS.coding.totalBudget).toBe(800); + }); +}); diff --git a/vendor/bytelyst/palace/src/config.ts b/vendor/bytelyst/palace/src/config.ts new file mode 100644 index 0000000..569c9b5 --- /dev/null +++ b/vendor/bytelyst/palace/src/config.ts @@ -0,0 +1,36 @@ +/** + * Palace configuration schema — Zod-based env var validation. + * + * Products extend their backend config with these palace-specific vars. + */ + +import { z } from 'zod'; + +export const palaceConfigSchema = z.object({ + PALACE_ENABLED: z + .enum(['true', 'false']) + .default('true') + .transform(v => v === 'true'), + + PALACE_WAKE_UP_BUDGET: z.coerce.number().default(800), + + PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.9), + + PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().min(1).default(30), + + PALACE_MAX_MEMORIES_PER_SEARCH: z.coerce.number().min(1).default(20), + + PALACE_EXTRACTION_MAX_CHARS: z.coerce.number().min(100).default(6000), +}); + +export type PalaceConfig = z.infer; + +/** + * Parse palace config from environment. + * Products typically merge this with their own config schema. + */ +export function parsePalaceConfig( + env: Record = process.env as Record +): PalaceConfig { + return palaceConfigSchema.parse(env); +} diff --git a/vendor/bytelyst/palace/src/cosine.ts b/vendor/bytelyst/palace/src/cosine.ts new file mode 100644 index 0000000..c6b5ae2 --- /dev/null +++ b/vendor/bytelyst/palace/src/cosine.ts @@ -0,0 +1,70 @@ +/** + * Vector similarity utilities for semantic search and deduplication. + */ + +/** + * Compute cosine similarity between two vectors. + * Returns a value between -1 and 1 (1 = identical direction). + * Returns 0 if either vector is zero-length or dimensions don't match. + */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length || a.length === 0) return 0; + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + const denominator = Math.sqrt(normA) * Math.sqrt(normB); + if (denominator === 0) return 0; + + return dotProduct / denominator; +} + +/** + * Normalize a vector to unit length (magnitude = 1). + * Returns a zero vector if input is zero-length. + */ +export function normalizeVector(v: number[]): number[] { + const magnitude = Math.sqrt(v.reduce((sum, val) => sum + val * val, 0)); + if (magnitude === 0) return v.map(() => 0); + return v.map(val => val / magnitude); +} + +/** + * Find the top-K most similar items to a query vector. + * + * @param query - The query embedding vector + * @param items - Array of items to search + * @param getEmbedding - Function to extract embedding from an item (returns undefined if missing) + * @param k - Maximum number of results to return + * @param minScore - Minimum cosine similarity score (default: 0) + * @returns Sorted array of { item, score } pairs, highest score first + */ +export function topKByCosine( + query: number[], + items: T[], + getEmbedding: (item: T) => number[] | undefined, + k: number, + minScore = 0 +): Array<{ item: T; score: number }> { + const scored: Array<{ item: T; score: number }> = []; + + for (const item of items) { + const embedding = getEmbedding(item); + if (!embedding || embedding.length === 0) continue; + + const score = cosineSimilarity(query, embedding); + if (score >= minScore) { + scored.push({ item, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} diff --git a/vendor/bytelyst/palace/src/decay.ts b/vendor/bytelyst/palace/src/decay.ts new file mode 100644 index 0000000..122e85f --- /dev/null +++ b/vendor/bytelyst/palace/src/decay.ts @@ -0,0 +1,52 @@ +/** + * Relevance decay for palace memories. + * + * Uses exponential half-life decay: relevance halves every N days. + * Memories that are accessed/referenced get their relevance boosted. + */ + +const MS_PER_DAY = 86_400_000; + +/** + * Compute decayed relevance using exponential half-life. + * + * @param originalRelevance - Initial relevance score (0-1) + * @param createdAt - ISO date string or Date when the memory was created/last accessed + * @param halfLifeDays - Number of days for relevance to halve (default: 30) + * @param asOf - Reference time for decay calculation (default: now) + * @returns Decayed relevance score (0-1) + */ +export function computeDecayedRelevance( + originalRelevance: number, + createdAt: string | Date, + halfLifeDays = 30, + asOf: Date = new Date() +): number { + if (halfLifeDays <= 0) return originalRelevance; + if (originalRelevance <= 0) return 0; + + const created = typeof createdAt === 'string' ? new Date(createdAt) : createdAt; + const elapsedMs = asOf.getTime() - created.getTime(); + + if (elapsedMs <= 0) return Math.min(originalRelevance, 1); + + const elapsedDays = elapsedMs / MS_PER_DAY; + const decayFactor = Math.pow(0.5, elapsedDays / halfLifeDays); + + return Math.max(0, Math.min(1, originalRelevance * decayFactor)); +} + +/** + * Compute a boosted relevance for a memory that was accessed/referenced. + * + * Boost formula: new = old + (1 - old) * boostFactor + * This asymptotically approaches 1.0 without exceeding it. + * + * @param currentRelevance - Current relevance score (0-1) + * @param boostFactor - How much of the remaining gap to close (default: 0.3) + * @returns Boosted relevance score (0-1) + */ +export function boostRelevance(currentRelevance: number, boostFactor = 0.3): number { + const boosted = currentRelevance + (1 - currentRelevance) * boostFactor; + return Math.min(1, Math.max(0, boosted)); +} diff --git a/vendor/bytelyst/palace/src/dedup.ts b/vendor/bytelyst/palace/src/dedup.ts new file mode 100644 index 0000000..9fe604e --- /dev/null +++ b/vendor/bytelyst/palace/src/dedup.ts @@ -0,0 +1,64 @@ +/** + * Deduplication utilities for palace memories. + * + * Detects near-duplicate content using cosine similarity over embeddings. + * Products handle the Cosmos/DB queries; this module operates on pure data. + */ + +import { cosineSimilarity } from './cosine.js'; + +/** + * Check if a candidate embedding is a near-duplicate of any existing embedding. + * + * @param candidate - Embedding of the new memory + * @param existingEmbeddings - Embeddings of existing memories in the same room/hall + * @param threshold - Cosine similarity threshold (default: 0.90) + * @returns true if any existing embedding exceeds the threshold + */ +export function isContentDuplicate( + candidate: number[], + existingEmbeddings: number[][], + threshold = 0.9 +): boolean { + for (const existing of existingEmbeddings) { + if (existing.length !== candidate.length) continue; + if (cosineSimilarity(candidate, existing) > threshold) { + return true; + } + } + return false; +} + +/** + * Check if two text strings are exact duplicates after normalization. + * Trims whitespace and lowercases before comparison. + */ +export function isExactDuplicate(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** + * Find the most similar embedding and return its index + score. + * Returns null if no embeddings exist or none exceed minScore. + */ +export function findClosestMatch( + candidate: number[], + existingEmbeddings: number[][], + minScore = 0 +): { index: number; score: number } | null { + let bestIndex = -1; + let bestScore = minScore; + + for (let i = 0; i < existingEmbeddings.length; i++) { + const existing = existingEmbeddings[i]; + if (existing.length !== candidate.length) continue; + + const score = cosineSimilarity(candidate, existing); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + return bestIndex >= 0 ? { index: bestIndex, score: bestScore } : null; +} diff --git a/vendor/bytelyst/palace/src/extraction.ts b/vendor/bytelyst/palace/src/extraction.ts new file mode 100644 index 0000000..bd3c304 --- /dev/null +++ b/vendor/bytelyst/palace/src/extraction.ts @@ -0,0 +1,154 @@ +/** + * Memory extraction utilities — prompt building, response parsing, regex fallback. + * + * Products call their own LLM provider with the prompt from buildExtractionPrompt(), + * then pass the response to parseExtractionResponse(). + * If LLM is unavailable, regexFallbackExtraction() provides basic extraction. + */ + +import type { ExtractedMemory } from './types.js'; + +export interface ExtractionContext { + title?: string; + context?: string; + hallTypes: readonly string[]; +} + +/** + * Build a structured extraction prompt for an LLM. + * + * @param content - The text content to extract memories from + * @param ctx - Context including title, additional context, and allowed hall types + * @returns A system/user prompt string ready for LLM chat() + */ +export function buildExtractionPrompt(content: string, ctx: ExtractionContext): string { + const hallList = ctx.hallTypes.join(', '); + const titleLine = ctx.title ? `\nTitle: ${ctx.title}` : ''; + const contextLine = ctx.context ? `\nContext: ${ctx.context}` : ''; + + return `Extract structured memories from the following content. + +For each distinct memory, return a JSON array where each element has: +- "hall": one of [${hallList}] +- "content": the memory summarized in 1-2 sentences +- "roomSlug": a short kebab-case topic slug (e.g. "auth-migration", "api-design") +- "entities": array of named entities mentioned (people, projects, technologies, places) + +Rules: +- Only extract genuinely important or referenceable facts, decisions, or events +- Skip trivial or obvious statements +- Each memory should be self-contained (understandable without the original context) +- Prefer specific details over vague summaries +- Return valid JSON only — no markdown fences, no explanation${titleLine}${contextLine} + +Content: +${content}`; +} + +/** + * Parse an LLM extraction response into ExtractedMemory[]. + * + * Handles: + * - Clean JSON arrays + * - JSON wrapped in markdown code fences + * - Malformed JSON (returns empty array) + */ +export function parseExtractionResponse(llmOutput: string): ExtractedMemory[] { + if (!llmOutput || llmOutput.trim().length === 0) return []; + + let cleaned = llmOutput.trim(); + + // Strip markdown code fences if present + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); + } + + try { + const parsed = JSON.parse(cleaned); + + if (!Array.isArray(parsed)) return []; + + return parsed + .filter( + (item: unknown): item is Record => + typeof item === 'object' && item !== null && 'hall' in item && 'content' in item + ) + .map(item => ({ + hall: String(item.hall || ''), + content: String(item.content || ''), + roomSlug: String(item.roomSlug || item.room_slug || 'general'), + entities: Array.isArray(item.entities) ? item.entities.map(String) : [], + })); + } catch { + return []; + } +} + +/** + * Regex-based fallback extraction when LLM is unavailable. + * + * Scans for common patterns: + * - "Decision:" / "Decided:" → decisions + * - "TODO:" / "Action:" → decisions + * - "Found:" / "Discovered:" / "Learned:" → discoveries + * - "Prefer:" / "Always:" / "Never:" → preferences + * - "Event:" / "Happened:" / date patterns → events + * - "Tip:" / "Note:" / "Remember:" → advice + * + * @param content - Raw text content + * @returns Array of extracted memories (best-effort) + */ +export function regexFallbackExtraction(content: string): ExtractedMemory[] { + const memories: ExtractedMemory[] = []; + const lines = content.split('\n'); + + const patterns: Array<{ regex: RegExp; hall: string }> = [ + { regex: /^(?:decision|decided|resolve[ds]?):\s*(.+)/i, hall: 'decisions' }, + { regex: /^(?:todo|action|task):\s*(.+)/i, hall: 'decisions' }, + { regex: /^(?:found|discovered|learned|til):\s*(.+)/i, hall: 'discoveries' }, + { regex: /^(?:prefer|always|never):\s*(.+)/i, hall: 'preferences' }, + { regex: /^(?:event|happened|occurred):\s*(.+)/i, hall: 'events' }, + { regex: /^(?:tip|note|remember|important):\s*(.+)/i, hall: 'advice' }, + { regex: /^(?:error|bug|issue|broken):\s*(.+)/i, hall: 'errors' }, + { regex: /^(?:pattern|recurring|trend):\s*(.+)/i, hall: 'patterns' }, + { regex: /^(?:feeling|mood|emotion):\s*(.+)/i, hall: 'emotions' }, + { regex: /^(?:insight|observation|noticed):\s*(.+)/i, hall: 'insights' }, + ]; + + for (const line of lines) { + const trimmed = line.replace(/^[\s\-*>#]+/, '').trim(); + if (!trimmed) continue; + + for (const { regex, hall } of patterns) { + const match = trimmed.match(regex); + if (match && match[1]) { + memories.push({ + hall, + content: match[1].trim(), + roomSlug: 'general', + entities: extractEntities(match[1]), + }); + break; + } + } + } + + return memories; +} + +/** + * Extract simple entities from text (mentions, tags, capitalized phrases). + */ +function extractEntities(text: string): string[] { + const entities = new Set(); + + // @mentions + const mentions = text.match(/@(\w+)/g); + if (mentions) mentions.forEach(m => entities.add(m.slice(1))); + + // #tags + const tags = text.match(/#(\w+)/g); + if (tags) tags.forEach(t => entities.add(t.slice(1))); + + return Array.from(entities); +} diff --git a/vendor/bytelyst/palace/src/halls.ts b/vendor/bytelyst/palace/src/halls.ts new file mode 100644 index 0000000..3e4220f --- /dev/null +++ b/vendor/bytelyst/palace/src/halls.ts @@ -0,0 +1,103 @@ +/** + * Hall types and presets for different products. + * + * Each product picks a preset (or defines custom halls). + * Halls categorize memories by type — decisions, events, discoveries, etc. + */ + +export const ALL_HALL_TYPES = [ + 'decisions', + 'events', + 'discoveries', + 'preferences', + 'advice', + 'insights', + 'patterns', + 'emotions', + 'errors', +] as const; + +export type HallType = (typeof ALL_HALL_TYPES)[number]; + +export interface HallPreset { + name: string; + halls: HallType[]; +} + +/** + * Product-specific hall presets. + * + * - notelett: insights instead of errors (note-taking domain) + * - mindlyst: patterns + emotions (multimodal/emotional domain) + * - coding: errors + advice (developer/agent domain, e.g. Claw-Cowork) + */ +export const HALL_PRESETS: Record = { + notelett: { + name: 'NoteLett', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'insights'], + }, + mindlyst: { + name: 'MindLyst', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'patterns', 'emotions'], + }, + coding: { + name: 'Coding Agent', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'errors'], + }, +}; + +/** + * Get a hall preset by name. + * Returns undefined if the preset does not exist. + */ +export function getHallPreset(presetName: string): HallPreset | undefined { + return HALL_PRESETS[presetName]; +} + +/** + * Classify a label string to the closest hall type. + * Case-insensitive, tries exact match first, then substring. + * Returns undefined if no match. + */ +export function hallFromLabel(label: string, allowedHalls?: HallType[]): HallType | undefined { + const normalized = label.toLowerCase().trim(); + const candidates = allowedHalls ?? (ALL_HALL_TYPES as unknown as HallType[]); + + // Exact match + const exact = candidates.find(h => h === normalized); + if (exact) return exact; + + // Substring match (e.g. "decision" → "decisions") + const partial = candidates.find(h => h.startsWith(normalized) || normalized.startsWith(h)); + if (partial) return partial; + + // Common synonyms + const synonymMap: Record = { + decision: 'decisions', + event: 'events', + discovery: 'discoveries', + preference: 'preferences', + insight: 'insights', + pattern: 'patterns', + emotion: 'emotions', + error: 'errors', + fact: 'discoveries', + finding: 'discoveries', + todo: 'decisions', + task: 'decisions', + bug: 'errors', + fix: 'decisions', + feeling: 'emotions', + mood: 'emotions', + trend: 'patterns', + recurring: 'patterns', + tip: 'advice', + recommendation: 'advice', + suggestion: 'advice', + }; + + const synonym = synonymMap[normalized]; + if (synonym && candidates.includes(synonym)) return synonym; + + return undefined; +} diff --git a/vendor/bytelyst/palace/src/index.ts b/vendor/bytelyst/palace/src/index.ts new file mode 100644 index 0000000..4689d58 --- /dev/null +++ b/vendor/bytelyst/palace/src/index.ts @@ -0,0 +1,48 @@ +// ── Types ────────────────────────────────────────────────────────── +export type { + BasePalaceWingDoc, + BasePalaceRoomDoc, + BasePalaceMemoryDoc, + BasePalaceTunnelDoc, + BasePalaceKGTripleDoc, + BasePalaceDiaryDoc, + ExtractedMemory, +} from './types.js'; + +// ── Halls ────────────────────────────────────────────────────────── +export { ALL_HALL_TYPES, HALL_PRESETS, getHallPreset, hallFromLabel } from './halls.js'; +export type { HallType, HallPreset } from './halls.js'; + +// ── Cosine Similarity ────────────────────────────────────────────── +export { cosineSimilarity, normalizeVector, topKByCosine } from './cosine.js'; + +// ── Deduplication ────────────────────────────────────────────────── +export { isContentDuplicate, isExactDuplicate, findClosestMatch } from './dedup.js'; + +// ── Relevance Decay ──────────────────────────────────────────────── +export { computeDecayedRelevance, boostRelevance } from './decay.js'; + +// ── Extraction ───────────────────────────────────────────────────── +export { + buildExtractionPrompt, + parseExtractionResponse, + regexFallbackExtraction, +} from './extraction.js'; +export type { ExtractionContext } from './extraction.js'; + +// ── Knowledge Graph ──────────────────────────────────────────────── +export { findContradictions, mergeTriples, isTripleCurrent } from './kg.js'; +export type { TripleInput } from './kg.js'; + +// ── Wake-Up Context ──────────────────────────────────────────────── +export { + buildWakeUpLayers, + truncateToTokenBudget, + estimateTokens, + WAKEUP_PRESETS, +} from './wakeup.js'; +export type { WakeUpConfig, WakeUpContext, WakeUpLayer } from './wakeup.js'; + +// ── Config ───────────────────────────────────────────────────────── +export { palaceConfigSchema, parsePalaceConfig } from './config.js'; +export type { PalaceConfig } from './config.js'; diff --git a/vendor/bytelyst/palace/src/kg.ts b/vendor/bytelyst/palace/src/kg.ts new file mode 100644 index 0000000..def0052 --- /dev/null +++ b/vendor/bytelyst/palace/src/kg.ts @@ -0,0 +1,138 @@ +/** + * Knowledge graph helpers for palace triple management. + * + * Triples are (subject, predicate, object) with temporal validity. + * This module provides pure functions for contradiction detection, + * merging, and currency checking. + */ + +/** + * A lightweight triple for comparison (does not require full doc fields). + */ +export interface TripleInput { + subject: string; + predicate: string; + object: string; + validFrom: string; + validTo?: string; + confidence?: number; +} + +/** + * Find contradictions between existing triples and incoming ones. + * + * A contradiction exists when: + * - Same subject + predicate, different object + * - Both are currently valid (no validTo or validTo in the future) + * + * @returns Array of { existing, incoming } contradiction pairs + */ +export function findContradictions( + existing: TripleInput[], + incoming: TripleInput[], + asOf: Date = new Date() +): Array<{ existing: TripleInput; incoming: TripleInput }> { + const contradictions: Array<{ existing: TripleInput; incoming: TripleInput }> = []; + + for (const inc of incoming) { + for (const ext of existing) { + if ( + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) !== normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) && + isTripleCurrent(inc, asOf) + ) { + contradictions.push({ existing: ext, incoming: inc }); + } + } + } + + return contradictions; +} + +/** + * Merge incoming triples into existing set. + * + * - If an incoming triple contradicts an existing one, the existing triple + * is invalidated (validTo set) and the incoming one is kept. + * - If an incoming triple is a duplicate (same S/P/O), it is skipped. + * - Otherwise, the incoming triple is added. + * + * @returns { merged, invalidated, added, skipped } counts + */ +export function mergeTriples( + existing: TripleInput[], + incoming: TripleInput[], + asOf: Date = new Date() +): { + merged: TripleInput[]; + invalidated: TripleInput[]; + added: TripleInput[]; + skipped: TripleInput[]; +} { + const invalidated: TripleInput[] = []; + const added: TripleInput[] = []; + const skipped: TripleInput[] = []; + + const merged = [...existing]; + + for (const inc of incoming) { + // Check for exact duplicate + const isDuplicate = merged.some( + ext => + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) === normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) + ); + + if (isDuplicate) { + skipped.push(inc); + continue; + } + + // Check for contradiction + const contradictIdx = merged.findIndex( + ext => + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) !== normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) + ); + + if (contradictIdx >= 0) { + // Invalidate the old triple + const old = merged[contradictIdx]; + merged[contradictIdx] = { ...old, validTo: asOf.toISOString() }; + invalidated.push(old); + } + + merged.push(inc); + added.push(inc); + } + + return { merged, invalidated, added, skipped }; +} + +/** + * Check if a triple is currently valid. + * + * @param triple - The triple to check + * @param asOf - Reference time (default: now) + * @returns true if the triple has no validTo or validTo is in the future + */ +export function isTripleCurrent( + triple: Pick, + asOf: Date = new Date() +): boolean { + if (!triple.validTo) return true; + return new Date(triple.validTo).getTime() > asOf.getTime(); +} + +/** + * Normalize an entity string for comparison (lowercase, trim, collapse whitespace). + */ +function normalizeEntity(s: string): string { + return s.toLowerCase().trim().replace(/\s+/g, ' '); +} diff --git a/vendor/bytelyst/palace/src/types.ts b/vendor/bytelyst/palace/src/types.ts new file mode 100644 index 0000000..d62f84b --- /dev/null +++ b/vendor/bytelyst/palace/src/types.ts @@ -0,0 +1,105 @@ +/** + * Base palace document types. + * + * Products extend these with product-specific fields + * (e.g. sourceWorkspaceId for NoteLett, sourceBrainId for MindLyst). + */ + +// ── Wing (top-level grouping — workspace/brain/project) ──────────── + +export interface BasePalaceWingDoc { + id: string; + productId: string; + userId: string; + name: string; + description?: string; + memoryCount: number; + l1Cache?: string; + l1CacheUpdatedAt?: string; + createdAt: string; + updatedAt: string; +} + +// ── Room (topic within a wing) ───────────────────────────────────── + +export interface BasePalaceRoomDoc { + id: string; + productId: string; + userId: string; + wingId: string; + name: string; + description?: string; + memoryCount: number; + createdAt: string; + updatedAt: string; +} + +// ── Memory (core unit — one fact/decision/event/etc.) ────────────── + +export interface BasePalaceMemoryDoc { + id: string; + productId: string; + userId: string; + wingId: string; + roomId: string; + hall: string; + content: string; + relevance: number; + embedding?: number[]; + sourceId?: string; + createdAt: string; + updatedAt: string; +} + +// ── Tunnel (cross-room/cross-wing link) ──────────────────────────── + +export interface BasePalaceTunnelDoc { + id: string; + productId: string; + userId: string; + fromMemoryId: string; + fromWingId: string; + toMemoryId: string; + toWingId: string; + relationship: string; + strength: number; + createdAt: string; +} + +// ── Knowledge Graph Triple ───────────────────────────────────────── + +export interface BasePalaceKGTripleDoc { + id: string; + productId: string; + userId: string; + wingId: string; + subject: string; + predicate: string; + object: string; + confidence: number; + validFrom: string; + validTo?: string; + sourceMemoryId?: string; + createdAt: string; +} + +// ── Diary Entry ──────────────────────────────────────────────────── + +export interface BasePalaceDiaryDoc { + id: string; + productId: string; + userId: string; + roleId: string; + wingId?: string; + entry: string; + createdAt: string; +} + +// ── Extracted memory (output of extraction pipeline) ─────────────── + +export interface ExtractedMemory { + hall: string; + content: string; + roomSlug: string; + entities: string[]; +} diff --git a/vendor/bytelyst/palace/src/wakeup.ts b/vendor/bytelyst/palace/src/wakeup.ts new file mode 100644 index 0000000..22717b3 --- /dev/null +++ b/vendor/bytelyst/palace/src/wakeup.ts @@ -0,0 +1,126 @@ +/** + * Wake-up context builder for palace-augmented sessions. + * + * Builds a layered context string (L0/L1/L2) within a token budget. + * Products provide the raw data; this module assembles and truncates. + */ + +export interface WakeUpLayer { + label: string; + content: string; + priority: number; +} + +export interface WakeUpConfig { + totalBudget: number; + l0Budget: number; + l1Budget: number; + l2Budget: number; +} + +export interface WakeUpContext { + text: string; + layers: { label: string; charCount: number }[]; + totalChars: number; + truncated: boolean; +} + +/** + * Approximate token count for a string. + * Uses the rough heuristic of ~4 characters per token. + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Truncate text to fit within a token budget. + * + * @param text - Input text + * @param maxTokens - Maximum tokens allowed + * @returns Truncated text (with "..." appended if truncated) + */ +export function truncateToTokenBudget(text: string, maxTokens: number): string { + if (!text) return ''; + + const maxChars = maxTokens * 4; + if (text.length <= maxChars) return text; + + // Truncate at word boundary + const truncated = text.slice(0, maxChars); + const lastSpace = truncated.lastIndexOf(' '); + const cutPoint = lastSpace > maxChars * 0.8 ? lastSpace : maxChars; + + return truncated.slice(0, cutPoint) + '...'; +} + +/** + * Build a wake-up context from L0/L1/L2 layers within a total token budget. + * + * Layer priority: + * - L0 (identity/project context) — always included, smallest budget + * - L1 (critical facts from recent memories) — high priority + * - L2 (semantically relevant memories) — fills remaining budget + * + * @param l0 - Identity/project context string + * @param l1 - Critical facts string + * @param l2 - Semantically relevant memories string + * @param config - Token budget configuration + * @returns Assembled wake-up context with metadata + */ +export function buildWakeUpLayers( + l0: string, + l1: string, + l2: string, + config: WakeUpConfig +): WakeUpContext { + const layers: { label: string; charCount: number }[] = []; + const parts: string[] = []; + let truncated = false; + + // L0: identity (always included) + const l0Truncated = truncateToTokenBudget(l0, config.l0Budget); + if (l0Truncated) { + parts.push(`[Identity]\n${l0Truncated}`); + layers.push({ label: 'L0:identity', charCount: l0Truncated.length }); + if (l0Truncated.endsWith('...')) truncated = true; + } + + // L1: critical facts + const l1Truncated = truncateToTokenBudget(l1, config.l1Budget); + if (l1Truncated) { + parts.push(`[Critical Facts]\n${l1Truncated}`); + layers.push({ label: 'L1:facts', charCount: l1Truncated.length }); + if (l1Truncated.endsWith('...')) truncated = true; + } + + // L2: semantic context (gets remaining budget) + const usedTokens = estimateTokens(parts.join('\n\n')); + const remainingBudget = Math.max(0, config.totalBudget - usedTokens); + const l2Budget = Math.min(config.l2Budget, remainingBudget); + + const l2Truncated = truncateToTokenBudget(l2, l2Budget); + if (l2Truncated) { + parts.push(`[Relevant Memories]\n${l2Truncated}`); + layers.push({ label: 'L2:semantic', charCount: l2Truncated.length }); + if (l2Truncated.endsWith('...')) truncated = true; + } + + const text = parts.join('\n\n'); + + return { + text, + layers, + totalChars: text.length, + truncated, + }; +} + +/** + * Default wake-up configs for each product. + */ +export const WAKEUP_PRESETS: Record = { + notelett: { totalBudget: 600, l0Budget: 50, l1Budget: 150, l2Budget: 400 }, + mindlyst: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, + coding: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, +}; diff --git a/vendor/bytelyst/palace/tsconfig.json b/vendor/bytelyst/palace/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/palace/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/platform-client/package.json b/vendor/bytelyst/platform-client/package.json new file mode 100644 index 0000000..79195c1 --- /dev/null +++ b/vendor/bytelyst/platform-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/platform-client", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe typed fetch wrapper 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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/platform-client/src/index.test.ts b/vendor/bytelyst/platform-client/src/index.test.ts new file mode 100644 index 0000000..e39a0cf --- /dev/null +++ b/vendor/bytelyst/platform-client/src/index.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createPlatformClient, ApiError } from './index.js'; + +describe('createPlatformClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should make GET requests with auth header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + const result = await api.get('/items'); + + expect(result).toEqual({ items: [] }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/items', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'x-product-id': 'testapp', + }), + }) + ); + }); + + it('should make POST requests with body', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: '1' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + const result = await api.post('/items', { name: 'test' }); + + expect(result).toEqual({ id: '1' }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/items', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'test' }), + }) + ); + }); + + it('should handle 204 No Content', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 204, + json: () => Promise.reject(new Error('no body')), + }) + ); + + const api = createPlatformClient(baseConfig); + const result = await api.del('/items/1'); + + expect(result).toBeUndefined(); + }); + + it('should throw ApiError on non-OK response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ message: 'Bad request' }), + }) + ); + + const api = createPlatformClient(baseConfig); + + await expect(api.get('/items')).rejects.toThrow(ApiError); + try { + await api.get('/items'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).status).toBe(400); + } + }); + + it('should attempt token refresh on 401', async () => { + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Unauthorized' }), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ refreshed: true }), + }); + }) + ); + + const refreshFn = vi.fn().mockResolvedValue(true); + const api = createPlatformClient({ + ...baseConfig, + refreshAccessToken: refreshFn, + }); + + const result = await api.get('/items'); + expect(refreshFn).toHaveBeenCalledOnce(); + expect(result).toEqual({ refreshed: true }); + }); + + it('should not include auth header when token is null', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient({ + ...baseConfig, + getAccessToken: () => null, + }); + await api.get('/public/items'); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('should include x-request-id header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + await api.get('/items'); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['x-request-id']).toBeDefined(); + expect(headers['x-request-id'].length).toBeGreaterThan(0); + }); +}); diff --git a/vendor/bytelyst/platform-client/src/index.ts b/vendor/bytelyst/platform-client/src/index.ts new file mode 100644 index 0000000..ca87d7d --- /dev/null +++ b/vendor/bytelyst/platform-client/src/index.ts @@ -0,0 +1,156 @@ +/** + * Browser/React Native-safe typed fetch wrapper for platform-service. + * + * Client-side counterpart to @bytelyst/api-client (which is server-side). + * Uses bearer tokens from storage (not httpOnly cookies). + * Includes auto-retry on 401 via token refresh. + * + * @example + * ```ts + * import { createPlatformClient } from '@bytelyst/platform-client'; + * + * const api = createPlatformClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * getAccessToken: () => authClient.getAccessToken(), + * refreshAccessToken: () => authClient.refreshAccessToken(), + * }); + * + * const sessions = await api.get('/fasting-sessions'); + * await api.post('/fasting-sessions', { protocol: '16:8' }); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export interface PlatformClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Function that returns the current access token, or null. */ + getAccessToken: () => string | null; + + /** Optional function to refresh the access token. Returns true on success. */ + refreshAccessToken?: () => Promise; + + /** Request timeout in milliseconds. Default: 15000. */ + timeoutMs?: number; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message?: string + ) { + super(message ?? `API error ${status}`); + this.name = 'ApiError'; + } +} + +export interface PlatformClient { + get(path: string, headers?: Record): Promise; + post(path: string, body?: unknown, headers?: Record): Promise; + put(path: string, body?: unknown, headers?: Record): Promise; + del(path: string, headers?: Record): Promise; + request( + method: string, + path: string, + body?: unknown, + headers?: Record + ): Promise; +} + +// ── UUID helper ────────────────────────────────────────────── + +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 createPlatformClient(config: PlatformClientConfig): PlatformClient { + const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config; + + async function doRequest( + method: string, + path: string, + body?: unknown, + extraHeaders?: Record, + isRetry = false + ): Promise { + const url = `${baseUrl}${path}`; + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': uuid(), + ...extraHeaders, + }; + + 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(url, { + method, + headers, + body: body != null ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (res.status === 204) return undefined as T; + + const json = await res.json().catch(() => ({})); + + // Auto-refresh on 401 (once) + if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) { + clearTimeout(timer); + const refreshed = await refreshAccessToken(); + if (refreshed) { + return doRequest(method, path, body, extraHeaders, true); + } + } + + if (!res.ok) { + throw new ApiError( + res.status, + json, + (json as Record).message ?? `HTTP ${res.status}` + ); + } + + return json as T; + } finally { + clearTimeout(timer); + } + } + + return { + get: (path: string, headers?: Record) => + doRequest('GET', path, undefined, headers), + post: (path: string, body?: unknown, headers?: Record) => + doRequest('POST', path, body, headers), + put: (path: string, body?: unknown, headers?: Record) => + doRequest('PUT', path, body, headers), + del: (path: string, headers?: Record) => + doRequest('DELETE', path, undefined, headers), + request: ( + method: string, + path: string, + body?: unknown, + headers?: Record + ) => doRequest(method, path, body, headers), + }; +} diff --git a/vendor/bytelyst/platform-client/tsconfig.json b/vendor/bytelyst/platform-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/platform-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/vendor/bytelyst/push/package.json b/vendor/bytelyst/push/package.json new file mode 100644 index 0000000..d8cb284 --- /dev/null +++ b/vendor/bytelyst/push/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bytelyst/push", + "version": "0.1.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/push/src/__tests__/push.test.ts b/vendor/bytelyst/push/src/__tests__/push.test.ts new file mode 100644 index 0000000..a543e0b --- /dev/null +++ b/vendor/bytelyst/push/src/__tests__/push.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for push notification providers. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MockPushProvider } from '../providers/mock.js'; + +describe('MockPushProvider', () => { + let provider: MockPushProvider; + + beforeEach(() => { + provider = new MockPushProvider(); + }); + + it('isConfigured returns true', () => { + expect(provider.isConfigured()).toBe(true); + }); + + it('send records notification', async () => { + const result = await provider.send({ + deviceToken: 'token-123', + platform: 'ios', + title: 'Test', + body: 'Hello', + }); + expect(result.success).toBe(true); + expect(result.messageId).toBeDefined(); + expect(provider.sent).toHaveLength(1); + expect(provider.sent[0]!.title).toBe('Test'); + }); + + it('sendBatch records all notifications', async () => { + const results = await provider.sendBatch([ + { deviceToken: 't1', platform: 'ios', title: 'A', body: 'a' }, + { deviceToken: 't2', platform: 'android', title: 'B', body: 'b' }, + ]); + expect(results).toHaveLength(2); + expect(results.every(r => r.success)).toBe(true); + expect(provider.sent).toHaveLength(2); + }); + + it('reset clears sent history', async () => { + await provider.send({ deviceToken: 't', platform: 'web', title: 'X', body: 'x' }); + provider.reset(); + expect(provider.sent).toHaveLength(0); + }); +}); diff --git a/vendor/bytelyst/push/src/factory.ts b/vendor/bytelyst/push/src/factory.ts new file mode 100644 index 0000000..b3693fb --- /dev/null +++ b/vendor/bytelyst/push/src/factory.ts @@ -0,0 +1,41 @@ +/** + * Push notification provider factory. + * + * Creates a PushProvider based on PUSH_PROVIDER env var. + * Defaults to 'mock' since no push infra is wired yet. + */ + +import { ExpoPushProvider } from './providers/expo.js'; +import { MockPushProvider } from './providers/mock.js'; +import type { PushProvider, PushProviderType } from './types.js'; + +let _provider: PushProvider | null = null; + +export function getPush(): PushProvider { + if (!_provider) { + const type = (process.env.PUSH_PROVIDER || 'mock') as PushProviderType; + _provider = createPushProvider(type); + } + return _provider; +} + +export function createPushProvider(type: PushProviderType): PushProvider { + switch (type) { + case 'expo': + return new ExpoPushProvider(); + case 'firebase': + throw new Error('Firebase push provider not yet implemented. Use expo or mock.'); + case 'mock': + return new MockPushProvider(); + default: + throw new Error(`Unknown PUSH_PROVIDER: '${type}'. Valid: expo, firebase, mock`); + } +} + +export function setPush(provider: PushProvider): void { + _provider = provider; +} + +export function _resetPush(): void { + _provider = null; +} diff --git a/vendor/bytelyst/push/src/index.ts b/vendor/bytelyst/push/src/index.ts new file mode 100644 index 0000000..b25caf6 --- /dev/null +++ b/vendor/bytelyst/push/src/index.ts @@ -0,0 +1,5 @@ +export type { PushProvider, PushNotification, PushResult, PushProviderType } from './types.js'; + +export { getPush, createPushProvider, setPush, _resetPush } from './factory.js'; +export { MockPushProvider } from './providers/mock.js'; +export { ExpoPushProvider } from './providers/expo.js'; diff --git a/vendor/bytelyst/push/src/providers/expo.ts b/vendor/bytelyst/push/src/providers/expo.ts new file mode 100644 index 0000000..df27795 --- /dev/null +++ b/vendor/bytelyst/push/src/providers/expo.ts @@ -0,0 +1,64 @@ +/** + * Expo push notification provider. + * + * Uses the Expo push notification service REST API. + * No SDK dependency — just HTTP requests. + */ + +import type { PushNotification, PushProvider, PushResult } from '../types.js'; + +const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; + +export class ExpoPushProvider implements PushProvider { + isConfigured(): boolean { + return true; // Expo push is open — no API key required for basic use + } + + async send(notification: PushNotification): Promise { + const results = await this.sendBatch([notification]); + return results[0]!; + } + + async sendBatch(notifications: PushNotification[]): Promise { + const messages = notifications.map(n => ({ + to: n.deviceToken, + title: n.title, + body: n.body, + data: n.data, + badge: n.badge, + sound: n.sound ?? 'default', + channelId: n.channelId, + })); + + try { + const response = await fetch(EXPO_PUSH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const text = await response.text(); + return notifications.map(() => ({ + success: false, + error: `Expo push error ${response.status}: ${text}`, + })); + } + + const data = (await response.json()) as { + data: Array<{ status: string; id?: string; message?: string }>; + }; + + return data.data.map(ticket => ({ + success: ticket.status === 'ok', + messageId: ticket.id, + error: ticket.status !== 'ok' ? ticket.message : undefined, + })); + } catch (err) { + return notifications.map(() => ({ + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + })); + } + } +} diff --git a/vendor/bytelyst/push/src/providers/mock.ts b/vendor/bytelyst/push/src/providers/mock.ts new file mode 100644 index 0000000..5e639fd --- /dev/null +++ b/vendor/bytelyst/push/src/providers/mock.ts @@ -0,0 +1,28 @@ +/** + * Mock push notification provider — for testing and local dev. + * + * Logs notifications instead of sending them. + */ + +import type { PushNotification, PushProvider, PushResult } from '../types.js'; + +export class MockPushProvider implements PushProvider { + public sent: PushNotification[] = []; + + isConfigured(): boolean { + return true; + } + + async send(notification: PushNotification): Promise { + this.sent.push(notification); + return { success: true, messageId: `mock-${Date.now()}-${this.sent.length}` }; + } + + async sendBatch(notifications: PushNotification[]): Promise { + return Promise.all(notifications.map(n => this.send(n))); + } + + reset(): void { + this.sent = []; + } +} diff --git a/vendor/bytelyst/push/src/testing.ts b/vendor/bytelyst/push/src/testing.ts new file mode 100644 index 0000000..5461036 --- /dev/null +++ b/vendor/bytelyst/push/src/testing.ts @@ -0,0 +1,18 @@ +/** + * Test helpers for @bytelyst/push. + */ + +import { setPush, _resetPush } from './factory.js'; +import { MockPushProvider } from './providers/mock.js'; + +export function setTestPushProvider(): MockPushProvider { + const provider = new MockPushProvider(); + setPush(provider); + return provider; +} + +export function resetTestPush(): void { + _resetPush(); +} + +export { MockPushProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/push/src/types.ts b/vendor/bytelyst/push/src/types.ts new file mode 100644 index 0000000..3779028 --- /dev/null +++ b/vendor/bytelyst/push/src/types.ts @@ -0,0 +1,36 @@ +/** + * Cloud-agnostic push notification interfaces. + * + * Provides a unified push API that works with + * Firebase, APNS, Expo, or mock/log providers. + */ + +export interface PushProvider { + /** Send a single push notification. */ + send(notification: PushNotification): Promise; + + /** Send a batch of push notifications. */ + sendBatch(notifications: PushNotification[]): Promise; + + /** Check if the push provider is configured. */ + isConfigured(): boolean; +} + +export interface PushNotification { + deviceToken: string; + platform: 'ios' | 'android' | 'web'; + title: string; + body: string; + data?: Record; + badge?: number; + sound?: string; + channelId?: string; +} + +export interface PushResult { + success: boolean; + messageId?: string; + error?: string; +} + +export type PushProviderType = 'firebase' | 'expo' | 'mock'; diff --git a/vendor/bytelyst/push/tsconfig.json b/vendor/bytelyst/push/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/push/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/queue/package.json b/vendor/bytelyst/queue/package.json new file mode 100644 index 0000000..3f13bc9 --- /dev/null +++ b/vendor/bytelyst/queue/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bytelyst/queue", + "version": "0.1.5", + "description": "Durable job queue with pluggable stores and worker runtime", + "type": "module", + "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 --pool forks" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.5" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/queue/src/file-store.ts b/vendor/bytelyst/queue/src/file-store.ts new file mode 100644 index 0000000..2484ac0 --- /dev/null +++ b/vendor/bytelyst/queue/src/file-store.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import type { EnqueueJobInput, ListJobsOptions, QueueJob, QueueStore } from './types.js'; + +export interface FileQueueStoreOptions { + filePath: string; +} + +export class FileQueueStore implements QueueStore { + private readonly filePath: string; + private operation = Promise.resolve(); + + constructor(options: FileQueueStoreOptions) { + this.filePath = options.filePath; + } + + enqueue( + queueName: string, + input: EnqueueJobInput + ): Promise> { + return this.withLock(async () => { + const state = await this.readState(); + const queue = state[queueName] ?? []; + if (input.idempotencyKey) { + const existing = queue.find(job => job.idempotencyKey === input.idempotencyKey); + if (existing) return existing as QueueJob; + } + + const now = new Date(); + const job: QueueJob = { + id: input.id || randomUUID(), + queueName, + type: input.type, + payload: input.payload, + status: 'queued', + attempts: 0, + maxAttempts: input.maxAttempts ?? 3, + createdAt: now.toISOString(), + scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), + progress: input.progress, + metadata: input.metadata, + idempotencyKey: input.idempotencyKey, + productId: input.productId, + userId: input.userId, + }; + state[queueName] = [...queue, job]; + await this.writeState(state); + return job; + }); + } + + get( + queueName: string, + id: string + ): Promise | undefined> { + return this.withLock(async () => { + const state = await this.readState(); + const job = (state[queueName] ?? []).find(item => item.id === id); + return job as QueueJob | undefined; + }); + } + + list( + queueName: string, + options?: ListJobsOptions + ): Promise>> { + return this.withLock(async () => { + const state = await this.readState(); + const jobs = (state[queueName] ?? []) + .filter(job => !options?.status || job.status === options.status) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, options?.limit ?? 50); + return jobs as Array>; + }); + } + + claimNext( + queueName: string, + workerId: string, + leaseMs: number, + now = new Date() + ): Promise | undefined> { + return this.withLock(async () => { + const state = await this.readState(); + const queue = state[queueName] ?? []; + const next = queue + .filter(job => this.isClaimable(job, now)) + .sort( + (a, b) => + a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) + )[0]; + if (!next) return undefined; + + next.status = 'running'; + next.attempts += 1; + next.startedAt = next.startedAt || now.toISOString(); + next.leaseOwner = workerId; + next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); + await this.writeState(state); + return next as QueueJob; + }); + } + + patch( + queueName: string, + id: string, + patch: Partial> + ): Promise | undefined> { + return this.withLock(async () => { + const state = await this.readState(); + const queue = state[queueName] ?? []; + const index = queue.findIndex(job => job.id === id); + if (index === -1) return undefined; + + const merged = { + ...queue[index], + ...patch, + }; + queue[index] = merged; + state[queueName] = queue; + await this.writeState(state); + return merged as QueueJob; + }); + } + + clear(queueName?: string): Promise { + return this.withLock(async () => { + if (!queueName) { + await this.writeState({}); + return; + } + const state = await this.readState(); + delete state[queueName]; + await this.writeState(state); + }); + } + + private async withLock(fn: () => Promise): Promise { + const run = this.operation.then(fn, fn); + this.operation = run.then( + () => undefined, + () => undefined + ); + return run; + } + + private async readState(): Promise> { + try { + const raw = await readFile(this.filePath, 'utf-8'); + return JSON.parse(raw) as Record; + } catch { + return {}; + } + } + + private async writeState(state: Record): Promise { + await mkdir(dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${randomUUID()}.tmp`; + await writeFile(tempPath, JSON.stringify(state, null, 2), 'utf-8'); + await rename(tempPath, this.filePath); + } + + private isClaimable(job: QueueJob, now: Date): boolean { + if (job.status === 'queued') { + return job.scheduledAt <= now.toISOString(); + } + if (job.status === 'running') { + return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); + } + return false; + } +} diff --git a/vendor/bytelyst/queue/src/index.ts b/vendor/bytelyst/queue/src/index.ts new file mode 100644 index 0000000..0ed27dd --- /dev/null +++ b/vendor/bytelyst/queue/src/index.ts @@ -0,0 +1,13 @@ +export type { + EnqueueJobInput, + ListJobsOptions, + QueueHandler, + QueueJob, + QueueJobStatus, + QueueStore, + QueueWorkerOptions, + WorkerContext, +} from './types.js'; +export { MemoryQueueStore, isTerminalStatus } from './memory-store.js'; +export { FileQueueStore, type FileQueueStoreOptions } from './file-store.js'; +export { QueueWorker } from './worker.js'; diff --git a/vendor/bytelyst/queue/src/memory-store.ts b/vendor/bytelyst/queue/src/memory-store.ts new file mode 100644 index 0000000..4eb06d6 --- /dev/null +++ b/vendor/bytelyst/queue/src/memory-store.ts @@ -0,0 +1,136 @@ +import { randomUUID } from 'node:crypto'; +import type { + EnqueueJobInput, + ListJobsOptions, + QueueJob, + QueueJobStatus, + QueueStore, +} from './types.js'; + +function cloneValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export class MemoryQueueStore implements QueueStore { + private queues = new Map>(); + + async enqueue( + queueName: string, + input: EnqueueJobInput + ): Promise> { + const queue = this.getQueue(queueName); + if (input.idempotencyKey) { + const existing = [...queue.values()].find(job => job.idempotencyKey === input.idempotencyKey); + if (existing) return cloneValue(existing) as QueueJob; + } + + const now = new Date(); + const job: QueueJob = { + id: input.id || randomUUID(), + queueName, + type: input.type, + payload: input.payload, + status: 'queued', + attempts: 0, + maxAttempts: input.maxAttempts ?? 3, + createdAt: now.toISOString(), + scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), + progress: input.progress, + metadata: input.metadata, + idempotencyKey: input.idempotencyKey, + productId: input.productId, + userId: input.userId, + }; + queue.set(job.id, cloneValue(job)); + return cloneValue(job); + } + + async get( + queueName: string, + id: string + ): Promise | undefined> { + const job = this.getQueue(queueName).get(id); + return job ? (cloneValue(job) as QueueJob) : undefined; + } + + async list( + queueName: string, + options?: ListJobsOptions + ): Promise>> { + const jobs = [...this.getQueue(queueName).values()] + .filter(job => !options?.status || job.status === options.status) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return cloneValue(jobs.slice(0, options?.limit ?? 50)) as Array>; + } + + async claimNext( + queueName: string, + workerId: string, + leaseMs: number, + now = new Date() + ): Promise | undefined> { + const queue = this.getQueue(queueName); + const candidates = [...queue.values()] + .filter(job => this.isClaimable(job, now)) + .sort( + (a, b) => + a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) + ); + const next = candidates[0]; + if (!next) return undefined; + + next.status = 'running'; + next.attempts += 1; + next.startedAt = next.startedAt || now.toISOString(); + next.leaseOwner = workerId; + next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); + queue.set(next.id, cloneValue(next)); + return cloneValue(next) as QueueJob; + } + + async patch( + queueName: string, + id: string, + patch: Partial> + ): Promise | undefined> { + const queue = this.getQueue(queueName); + const current = queue.get(id); + if (!current) return undefined; + + const merged = { + ...current, + ...patch, + }; + queue.set(id, cloneValue(merged)); + return cloneValue(merged) as QueueJob; + } + + async clear(queueName?: string): Promise { + if (queueName) { + this.queues.delete(queueName); + return; + } + this.queues.clear(); + } + + private getQueue(queueName: string): Map { + if (!this.queues.has(queueName)) { + this.queues.set(queueName, new Map()); + } + return this.queues.get(queueName)!; + } + + private isClaimable(job: QueueJob, now: Date): boolean { + if (job.status === 'queued') { + return job.scheduledAt <= now.toISOString(); + } + if (job.status === 'running') { + return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); + } + return false; + } +} + +export function isTerminalStatus(status: QueueJobStatus): boolean { + return ['succeeded', 'failed', 'dead_letter', 'cancelled'].includes(status); +} diff --git a/vendor/bytelyst/queue/src/queue.test.ts b/vendor/bytelyst/queue/src/queue.test.ts new file mode 100644 index 0000000..a56c5c3 --- /dev/null +++ b/vendor/bytelyst/queue/src/queue.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { FileQueueStore } from './file-store.js'; +import { MemoryQueueStore } from './memory-store.js'; +import { QueueWorker } from './worker.js'; + +describe('MemoryQueueStore', () => { + it('enqueues and retrieves jobs', async () => { + const store = new MemoryQueueStore(); + const job = await store.enqueue('test', { + type: 'demo', + payload: { value: 1 }, + }); + + const found = await store.get('test', job.id); + expect(found?.payload).toEqual({ value: 1 }); + expect(found?.status).toBe('queued'); + }); +}); + +describe('FileQueueStore', () => { + it('persists jobs across store instances', async () => { + const dir = await mkdtemp(join(tmpdir(), 'queue-store-')); + const filePath = join(dir, 'jobs.json'); + + const first = new FileQueueStore({ filePath }); + const job = await first.enqueue('persisted', { + type: 'demo', + payload: { ok: true }, + }); + + const second = new FileQueueStore({ filePath }); + const found = await second.get('persisted', job.id); + expect(found?.payload).toEqual({ ok: true }); + + const raw = JSON.parse(await readFile(filePath, 'utf-8')) as Record< + string, + Array<{ id: string }> + >; + expect(raw.persisted[0].id).toBe(job.id); + }); +}); + +describe('QueueWorker', () => { + it('processes queued jobs to completion', async () => { + const store = new MemoryQueueStore(); + const worker = new QueueWorker<{ value: number }, { doubled: number }>({ + queueName: 'work', + store, + pollIntervalMs: 5, + handler: async job => ({ doubled: job.payload.value * 2 }), + }); + + worker.start(); + const job = await store.enqueue('work', { + type: 'double', + payload: { value: 21 }, + }); + + await vi.waitFor(async () => { + const updated = await store.get<{ value: number }, { doubled: number }>('work', job.id); + expect(updated?.status).toBe('succeeded'); + expect(updated?.result).toEqual({ doubled: 42 }); + }); + + await worker.stop(); + }); +}); diff --git a/vendor/bytelyst/queue/src/types.ts b/vendor/bytelyst/queue/src/types.ts new file mode 100644 index 0000000..cafc7dd --- /dev/null +++ b/vendor/bytelyst/queue/src/types.ts @@ -0,0 +1,95 @@ +export type QueueJobStatus = + | 'queued' + | 'running' + | 'succeeded' + | 'failed' + | 'dead_letter' + | 'cancelled'; + +export interface QueueJob { + id: string; + queueName: string; + type: string; + payload: TPayload; + status: QueueJobStatus; + attempts: number; + maxAttempts: number; + createdAt: string; + scheduledAt: string; + startedAt?: string; + completedAt?: string; + lastError?: string; + result?: TResult; + progress?: Record; + metadata?: Record; + leaseOwner?: string; + leaseExpiresAt?: string; + idempotencyKey?: string; + productId?: string; + userId?: string; +} + +export interface EnqueueJobInput { + id?: string; + type: string; + payload: TPayload; + maxAttempts?: number; + delayMs?: number; + progress?: Record; + metadata?: Record; + idempotencyKey?: string; + productId?: string; + userId?: string; +} + +export interface ListJobsOptions { + limit?: number; + status?: QueueJobStatus; +} + +export interface QueueStore { + enqueue( + queueName: string, + input: EnqueueJobInput + ): Promise>; + get( + queueName: string, + id: string + ): Promise | undefined>; + list( + queueName: string, + options?: ListJobsOptions + ): Promise>>; + claimNext( + queueName: string, + workerId: string, + leaseMs: number, + now?: Date + ): Promise | undefined>; + patch( + queueName: string, + id: string, + patch: Partial> + ): Promise | undefined>; + clear(queueName?: string): Promise; +} + +export interface WorkerContext { + patch(patch: Partial>): Promise; + heartbeat(): Promise; +} + +export type QueueHandler = ( + job: QueueJob, + context: WorkerContext +) => Promise; + +export interface QueueWorkerOptions { + queueName: string; + store: QueueStore; + handler: QueueHandler; + workerId?: string; + pollIntervalMs?: number; + leaseMs?: number; + backoffMs?: number; +} diff --git a/vendor/bytelyst/queue/src/worker.ts b/vendor/bytelyst/queue/src/worker.ts new file mode 100644 index 0000000..7ed687c --- /dev/null +++ b/vendor/bytelyst/queue/src/worker.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto'; +import type { QueueWorkerOptions, WorkerContext } from './types.js'; + +export class QueueWorker { + private readonly queueName: string; + private readonly store: QueueWorkerOptions['store']; + private readonly handler: QueueWorkerOptions['handler']; + private readonly workerId: string; + private readonly pollIntervalMs: number; + private readonly leaseMs: number; + private readonly backoffMs: number; + private timer?: ReturnType; + private running = false; + private inflight?: Promise; + + constructor(options: QueueWorkerOptions) { + this.queueName = options.queueName; + this.store = options.store; + this.handler = options.handler; + this.workerId = options.workerId || `worker_${randomUUID()}`; + this.pollIntervalMs = options.pollIntervalMs ?? 200; + this.leaseMs = options.leaseMs ?? 30_000; + this.backoffMs = options.backoffMs ?? 1_000; + } + + start(): void { + if (this.running) return; + this.running = true; + this.schedule(0); + } + + async stop(): Promise { + this.running = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + await this.inflight; + } + + private schedule(delayMs: number): void { + if (!this.running) return; + this.timer = globalThis.setTimeout(() => { + this.inflight = this.tick().finally(() => { + this.inflight = undefined; + }); + }, delayMs); + } + + private async tick(): Promise { + const job = await this.store.claimNext( + this.queueName, + this.workerId, + this.leaseMs + ); + if (!job) { + this.schedule(this.pollIntervalMs); + return; + } + + const context: WorkerContext = { + patch: async patch => { + await this.store.patch(this.queueName, job.id, patch); + }, + heartbeat: async () => { + await this.store.patch(this.queueName, job.id, { + leaseOwner: this.workerId, + leaseExpiresAt: new Date(Date.now() + this.leaseMs).toISOString(), + }); + }, + }; + + try { + const result = await this.handler(job, context); + await this.store.patch(this.queueName, job.id, { + status: 'succeeded', + result, + completedAt: new Date().toISOString(), + leaseOwner: undefined, + leaseExpiresAt: undefined, + }); + } catch (err: unknown) { + const lastError = err instanceof Error ? err.message : String(err); + const finalStatus = job.attempts >= job.maxAttempts ? 'dead_letter' : 'queued'; + await this.store.patch(this.queueName, job.id, { + status: finalStatus, + lastError, + scheduledAt: + finalStatus === 'queued' + ? new Date(Date.now() + this.backoffMs * job.attempts).toISOString() + : job.scheduledAt, + completedAt: finalStatus === 'dead_letter' ? new Date().toISOString() : undefined, + leaseOwner: undefined, + leaseExpiresAt: undefined, + }); + } + + this.schedule(0); + } +} diff --git a/vendor/bytelyst/queue/tsconfig.json b/vendor/bytelyst/queue/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/queue/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/quick-actions/package.json b/vendor/bytelyst/quick-actions/package.json new file mode 100644 index 0000000..0237e0c --- /dev/null +++ b/vendor/bytelyst/quick-actions/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/quick-actions", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/quick-actions/src/client.test.ts b/vendor/bytelyst/quick-actions/src/client.test.ts new file mode 100644 index 0000000..56ebf00 --- /dev/null +++ b/vendor/bytelyst/quick-actions/src/client.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + getVisibleSections, + getAvailableActions, + pickSmartDefault, + MAX_VISIBLE_ITEMS, + MAX_VISIBLE_LIST, +} from './client.js'; +import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; + +describe('getVisibleSections', () => { + const sections: ProgressiveSection[] = [ + { id: 'main', title: 'Main', defaultExpanded: true, priority: 'primary' }, + { id: 'stats', title: 'Stats', defaultExpanded: false, priority: 'secondary' }, + { id: 'details', title: 'Details', defaultExpanded: false, priority: 'detail' }, + { id: 'advanced', title: 'Advanced', defaultExpanded: false, priority: 'primary' }, + ]; + + it('should show primary and defaultExpanded sections', () => { + const visible = getVisibleSections(sections, new Set()); + expect(visible.map(s => s.id)).toEqual(['main', 'advanced']); + }); + + it('should include explicitly expanded sections', () => { + const visible = getVisibleSections(sections, new Set(['stats'])); + expect(visible.map(s => s.id)).toEqual(['main', 'stats', 'advanced']); + }); + + it('should return empty for no sections', () => { + expect(getVisibleSections([], new Set())).toHaveLength(0); + }); +}); + +describe('getAvailableActions', () => { + const actions: QuickAction[] = [ + { + id: 'start', + label: 'Start', + icon: 'play', + shortLabel: 'Start', + action: 'start', + requiresAuth: false, + }, + { + id: 'share', + label: 'Share', + icon: 'share', + shortLabel: 'Share', + action: 'share', + requiresAuth: true, + }, + { + id: 'stop', + label: 'Stop', + icon: 'stop', + shortLabel: 'Stop', + action: 'stop', + requiresAuth: false, + }, + ]; + + it('should filter out auth-required actions when not authenticated', () => { + const available = getAvailableActions(actions, { isAuthenticated: false }); + expect(available.map(a => a.id)).toEqual(['start', 'stop']); + }); + + it('should include all when authenticated', () => { + const available = getAvailableActions(actions, { isAuthenticated: true }); + expect(available).toHaveLength(3); + }); + + it('should include non-auth actions by default', () => { + const available = getAvailableActions(actions, {}); + expect(available.map(a => a.id)).toEqual(['start', 'stop']); + }); +}); + +describe('pickSmartDefault', () => { + it('should prefer last_used over others', () => { + const candidates: SmartDefault[] = [ + { key: 'protocol', value: 'OMAD', source: 'most_common' }, + { key: 'protocol', value: '16:8', source: 'last_used' }, + { key: 'protocol', value: '18:6', source: 'recommendation' }, + ]; + const result = pickSmartDefault(candidates); + expect(result?.value).toBe('16:8'); + }); + + it('should fall back to most_common', () => { + const candidates: SmartDefault[] = [ + { key: 'protocol', value: 'OMAD', source: 'most_common' }, + { key: 'protocol', value: '18:6', source: 'system' }, + ]; + const result = pickSmartDefault(candidates); + expect(result?.value).toBe('OMAD'); + }); + + it('should return null for empty array', () => { + expect(pickSmartDefault([])).toBeNull(); + }); + + it('should return first if no priority match', () => { + const candidates: SmartDefault[] = [{ key: 'x', value: 'a', source: 'system' }]; + const result = pickSmartDefault(candidates); + expect(result?.value).toBe('a'); + }); +}); + +describe('constants', () => { + it('should export MAX_VISIBLE_ITEMS', () => { + expect(MAX_VISIBLE_ITEMS).toBe(3); + }); + + it('should export MAX_VISIBLE_LIST', () => { + expect(MAX_VISIBLE_LIST).toBe(5); + }); +}); diff --git a/vendor/bytelyst/quick-actions/src/client.ts b/vendor/bytelyst/quick-actions/src/client.ts new file mode 100644 index 0000000..422b0be --- /dev/null +++ b/vendor/bytelyst/quick-actions/src/client.ts @@ -0,0 +1,47 @@ +/** + * Progressive disclosure system, smart defaults, quick action definitions. + * + * Reduces cognitive load by surfacing only relevant UI sections and actions. + * Pure client-side TS — no backend dependency. + */ + +import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; + +export const MAX_VISIBLE_ITEMS = 3; +export const MAX_VISIBLE_LIST = 5; + +export function getVisibleSections( + sections: ProgressiveSection[], + expandedIds: Set +): ProgressiveSection[] { + return sections.filter( + s => s.defaultExpanded || expandedIds.has(s.id) || s.priority === 'primary' + ); +} + +export function getAvailableActions( + actions: QuickAction[], + context: { isActive?: boolean; isAuthenticated?: boolean } +): QuickAction[] { + return actions.filter(a => { + if (a.requiresAuth && !context.isAuthenticated) return false; + return true; + }); +} + +export function pickSmartDefault(candidates: SmartDefault[]): SmartDefault | null { + if (candidates.length === 0) return null; + + const priority: SmartDefault['source'][] = [ + 'last_used', + 'most_common', + 'recommendation', + 'system', + ]; + for (const source of priority) { + const match = candidates.find(c => c.source === source); + if (match) return match; + } + + return candidates[0]; +} diff --git a/vendor/bytelyst/quick-actions/src/index.ts b/vendor/bytelyst/quick-actions/src/index.ts new file mode 100644 index 0000000..08b63e9 --- /dev/null +++ b/vendor/bytelyst/quick-actions/src/index.ts @@ -0,0 +1,27 @@ +export interface QuickAction { + id: string; + label: string; + icon?: string; + requiresAuth?: boolean; + priority?: number; +} + +export const MAX_VISIBLE_ITEMS = 4; +export const MAX_VISIBLE_LIST = 8; + +export function getAvailableActions( + actions: QuickAction[], + opts?: { isAuthenticated?: boolean }, +): QuickAction[] { + const isAuthenticated = opts?.isAuthenticated ?? false; + return actions.filter( + (a) => !a.requiresAuth || isAuthenticated, + ); +} + +export function pickSmartDefault(defaults: QuickAction[]): QuickAction | null { + if (defaults.length === 0) { + return null; + } + return defaults[0] ?? null; +} diff --git a/vendor/bytelyst/quick-actions/src/types.ts b/vendor/bytelyst/quick-actions/src/types.ts new file mode 100644 index 0000000..ce86fdd --- /dev/null +++ b/vendor/bytelyst/quick-actions/src/types.ts @@ -0,0 +1,26 @@ +/** + * Types for @bytelyst/quick-actions. + * Pure client-side TS — no backend dependency. + */ + +export interface QuickAction { + id: string; + label: string; + icon: string; + shortLabel: string; + action: string; + requiresAuth: boolean; +} + +export interface ProgressiveSection { + id: string; + title: string; + defaultExpanded: boolean; + priority: 'primary' | 'secondary' | 'detail'; +} + +export interface SmartDefault { + key: string; + value: unknown; + source: 'last_used' | 'most_common' | 'recommendation' | 'system'; +} diff --git a/vendor/bytelyst/quick-actions/tsconfig.json b/vendor/bytelyst/quick-actions/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/quick-actions/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/react-auth/package.json b/vendor/bytelyst/react-auth/package.json index 74b1270..0d04cc7 100644 --- a/vendor/bytelyst/react-auth/package.json +++ b/vendor/bytelyst/react-auth/package.json @@ -21,7 +21,7 @@ "react": ">=18.0.0" }, "dependencies": { - "@bytelyst/api-client": "file:../api-client" + "@bytelyst/api-client": "workspace:*" }, "devDependencies": { "@testing-library/react": "^16.3.2", diff --git a/vendor/bytelyst/referral-client/package.json b/vendor/bytelyst/referral-client/package.json new file mode 100644 index 0000000..b922a34 --- /dev/null +++ b/vendor/bytelyst/referral-client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/referral-client", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/referral-client/src/client.test.ts b/vendor/bytelyst/referral-client/src/client.test.ts new file mode 100644 index 0000000..24eeafe --- /dev/null +++ b/vendor/bytelyst/referral-client/src/client.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createReferralClient } from './client.js'; +import type { ReferralDoc } from './types.js'; + +const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', +}; + +function mockReferral(overrides?: Partial): ReferralDoc { + return { + id: 'ref-1', + productId: 'testapp', + referrerId: 'user-1', + referrerEmail: 'alice@test.com', + referredUserId: null, + referredEmail: 'bob@test.com', + status: 'pending', + referrerRewardTokens: 1000, + referredRewardTokens: 500, + referrerRewarded: false, + referredRewarded: false, + createdAt: '2026-01-01T00:00:00Z', + completedAt: null, + ...overrides, + }; +} + +describe('createReferralClient', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create a referral', async () => { + const ref = mockReferral(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(ref), + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.createReferral({ + referrerId: 'user-1', + referrerEmail: 'alice@test.com', + referredEmail: 'bob@test.com', + }); + + expect(result).toEqual(ref); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/referrals', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should list referrals by referrerId', async () => { + const data = { referrals: [mockReferral()], count: 1 }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(data), + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.listMyReferrals('user-1'); + + expect(result.count).toBe(1); + expect(result.referrals).toHaveLength(1); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/referrals/by-referrer/user-1', + expect.any(Object) + ); + }); + + it('should get referral stats', async () => { + const stats = { total: 10, completed: 5, rewarded: 3 }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(stats), + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.getReferralStats(); + expect(result).toEqual(stats); + }); + + it('should update referral status via PUT', async () => { + const updated = mockReferral({ status: 'signed_up' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(updated), + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.updateReferralStatus('ref-1', 'user-1', 'signed_up'); + + expect(result.status).toBe('signed_up'); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/referrals/ref-1', + expect.objectContaining({ method: 'PUT' }) + ); + }); + + it('should get referral by email', async () => { + const ref = mockReferral(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(ref), + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.getByEmail('bob@test.com'); + expect(result).toEqual(ref); + }); + + it('should return null for 404 on getByEmail', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + ); + + const client = createReferralClient(baseConfig); + const result = await client.getByEmail('unknown@test.com'); + expect(result).toBeNull(); + }); + + it('should send correct headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ total: 0, completed: 0, rewarded: 0 }), + }) + ); + + const client = createReferralClient(baseConfig); + await client.getReferralStats(); + + const fetchMock = globalThis.fetch as ReturnType; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer test-token'); + expect(callHeaders['x-request-id']).toBeDefined(); + }); + + it('should throw on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + + const client = createReferralClient(baseConfig); + await expect(client.getReferralStats()).rejects.toThrow('getReferralStats failed: 500'); + }); + + it('should build share link', () => { + const client = createReferralClient(baseConfig); + const link = client.buildShareLink('ABC123'); + expect(link).toContain('refer/ABC123'); + expect(link).toContain('product=testapp'); + }); + + it('should build share message', () => { + const client = createReferralClient(baseConfig); + const msg = client.buildShareMessage('ABC123', 'NomGap'); + expect(msg).toContain('NomGap'); + expect(msg).toContain('refer/ABC123'); + }); + + it('should calculate earned days', () => { + const client = createReferralClient(baseConfig); + expect(client.calculateEarnedDays(3)).toBe(21); + expect(client.calculateEarnedDays(0)).toBe(0); + expect(client.calculateEarnedDays(2, 14)).toBe(28); + }); + + it('should use custom default reward tokens', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockReferral()), + }) + ); + + const client = createReferralClient({ + ...baseConfig, + defaultRewardTokens: { referrer: 2000, referred: 1000 }, + }); + await client.createReferral({ + referrerId: 'user-1', + referrerEmail: 'alice@test.com', + referredEmail: 'bob@test.com', + }); + + const fetchMock = globalThis.fetch as ReturnType; + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.referrerRewardTokens).toBe(2000); + expect(body.referredRewardTokens).toBe(1000); + }); +}); diff --git a/vendor/bytelyst/referral-client/src/client.ts b/vendor/bytelyst/referral-client/src/client.ts new file mode 100644 index 0000000..3dac261 --- /dev/null +++ b/vendor/bytelyst/referral-client/src/client.ts @@ -0,0 +1,122 @@ +/** + * Browser/React Native-safe referral client for platform-service. + * + * Wraps platform-service /referrals/* endpoints. + * No Node.js dependencies — uses globalThis.fetch. + */ + +import type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js'; + +function generateRequestId(): string { + return typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createReferralClient(config: ReferralClientConfig): ReferralClient { + const { baseUrl, productId, getAccessToken, defaultRewardTokens } = config; + + const defaultReferrerTokens = defaultRewardTokens?.referrer ?? 1000; + const defaultReferredTokens = defaultRewardTokens?.referred ?? 500; + + function headers(): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': generateRequestId(), + }; + const token = getAccessToken(); + if (token) h['Authorization'] = `Bearer ${token}`; + return h; + } + + async function listMyReferrals( + referrerId: string + ): Promise<{ referrals: ReferralDoc[]; count: number }> { + const res = await globalThis.fetch( + `${baseUrl}/referrals/by-referrer/${encodeURIComponent(referrerId)}`, + { headers: headers() } + ); + if (!res.ok) throw new Error(`listMyReferrals failed: ${res.status}`); + const data = (await res.json()) as { referrals: ReferralDoc[]; count: number }; + return data; + } + + async function getReferralStats(): Promise<{ + total: number; + completed: number; + rewarded: number; + }> { + const res = await globalThis.fetch(`${baseUrl}/referrals/stats`, { headers: headers() }); + if (!res.ok) throw new Error(`getReferralStats failed: ${res.status}`); + return (await res.json()) as { total: number; completed: number; rewarded: number }; + } + + async function createReferral(input: { + referrerId: string; + referrerEmail: string; + referredEmail: string; + }): Promise { + const body = { + ...input, + productId, + referrerRewardTokens: defaultReferrerTokens, + referredRewardTokens: defaultReferredTokens, + }; + const res = await globalThis.fetch(`${baseUrl}/referrals`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`createReferral failed: ${res.status}`); + return (await res.json()) as ReferralDoc; + } + + async function updateReferralStatus( + id: string, + referrerId: string, + status: ReferralDoc['status'] + ): Promise { + const res = await globalThis.fetch(`${baseUrl}/referrals/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: headers(), + body: JSON.stringify({ referrerId, status }), + }); + if (!res.ok) throw new Error(`updateReferralStatus failed: ${res.status}`); + return (await res.json()) as ReferralDoc; + } + + async function getByEmail(email: string): Promise { + const res = await globalThis.fetch( + `${baseUrl}/referrals/by-email/${encodeURIComponent(email)}`, + { headers: headers() } + ); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`getByEmail failed: ${res.status}`); + return (await res.json()) as ReferralDoc; + } + + function buildShareLink(code: string): string { + return `https://bytelyst.com/refer/${encodeURIComponent(code)}?product=${encodeURIComponent(productId)}`; + } + + function buildShareMessage(code: string, productName: string): string { + const link = buildShareLink(code); + return `Try ${productName}! Use my referral link to get started: ${link}`; + } + + function calculateEarnedDays(conversions: number, daysPerReferral = 7): number { + return Math.max(0, conversions * daysPerReferral); + } + + return { + listMyReferrals, + getReferralStats, + createReferral, + updateReferralStatus, + getByEmail, + buildShareLink, + buildShareMessage, + calculateEarnedDays, + }; +} diff --git a/vendor/bytelyst/referral-client/src/index.ts b/vendor/bytelyst/referral-client/src/index.ts new file mode 100644 index 0000000..d021d6e --- /dev/null +++ b/vendor/bytelyst/referral-client/src/index.ts @@ -0,0 +1,72 @@ +export interface ReferralClientOptions { + baseUrl: string; + productId: string; + getAccessToken: () => string; +} + +export interface ReferralStats { + totalReferrals: number; + successfulReferrals: number; + pendingReferrals: number; + rewardsEarned: number; + referralCode: string; +} + +export interface ReferralInfo { + code: string; + referrerEmail: string; + status: string; +} + +function joinUrl(base: string, path: string): string { + const b = base.replace(/\/$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + return `${b}${p}`; +} + +function headers(opts: ReferralClientOptions): HeadersInit { + return { + Authorization: `Bearer ${opts.getAccessToken()}`, + "X-Product-Id": opts.productId, + Accept: "application/json", + }; +} + +async function parseJson(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export function createReferralClient(opts: ReferralClientOptions) { + const { baseUrl } = opts; + + return { + async getReferralStats(): Promise { + const res = await fetch(joinUrl(baseUrl, "/referrals/stats"), { + method: "GET", + headers: headers(opts), + }); + return parseJson(res); + }, + + buildShareLink(code: string): string { + const b = baseUrl.replace(/\/$/, ""); + return `${b}/r/${code}`; + }, + + buildShareMessage(code: string, productName: string): string { + return `Try ${productName}! Use my referral code: ${code}`; + }, + + async getByEmail(code: string): Promise { + const res = await fetch( + joinUrl(baseUrl, `/referrals/${encodeURIComponent(code)}`), + { method: "GET", headers: headers(opts) } + ); + return parseJson(res); + }, + }; +} diff --git a/vendor/bytelyst/referral-client/src/types.ts b/vendor/bytelyst/referral-client/src/types.ts new file mode 100644 index 0000000..5067449 --- /dev/null +++ b/vendor/bytelyst/referral-client/src/types.ts @@ -0,0 +1,55 @@ +/** + * Types for @bytelyst/referral-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface ReferralDoc { + id: string; + productId: string; + referrerId: string; + referrerEmail: string; + referredUserId: string | null; + referredEmail: string; + status: 'pending' | 'signed_up' | 'subscribed' | 'rewarded'; + referrerRewardTokens: number; + referredRewardTokens: number; + referrerRewarded: boolean; + referredRewarded: boolean; + createdAt: string; + completedAt: string | null; +} + +export interface ReferralClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header on every request. */ + productId: string; + + /** Returns a JWT access token, or null if not authenticated. */ + getAccessToken: () => string | null; + + /** Default reward tokens applied when creating referrals. */ + defaultRewardTokens?: { referrer: number; referred: number }; +} + +export interface ReferralClient { + listMyReferrals(referrerId: string): Promise<{ referrals: ReferralDoc[]; count: number }>; + getReferralStats(): Promise<{ total: number; completed: number; rewarded: number }>; + createReferral(input: { + referrerId: string; + referrerEmail: string; + referredEmail: string; + }): Promise; + updateReferralStatus( + id: string, + referrerId: string, + status: ReferralDoc['status'] + ): Promise; + getByEmail(email: string): Promise; + + // Client-side helpers (pure TS, no network) + buildShareLink(code: string): string; + buildShareMessage(code: string, productName: string): string; + calculateEarnedDays(conversions: number, daysPerReferral?: number): number; +} diff --git a/vendor/bytelyst/referral-client/tsconfig.json b/vendor/bytelyst/referral-client/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/referral-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/secure-storage-web/package.json b/vendor/bytelyst/secure-storage-web/package.json new file mode 100644 index 0000000..39a42bd --- /dev/null +++ b/vendor/bytelyst/secure-storage-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/secure-storage-web", + "version": "0.1.5", + "type": "module", + "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 --pool forks" + }, + "devDependencies": { + "vitest": "^3.0.0", + "fake-indexeddb": "^6.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/secure-storage-web/src/index.ts b/vendor/bytelyst/secure-storage-web/src/index.ts new file mode 100644 index 0000000..15919c3 --- /dev/null +++ b/vendor/bytelyst/secure-storage-web/src/index.ts @@ -0,0 +1,19 @@ +/** + * @bytelyst/secure-storage-web + * + * Encrypted key-value storage for web applications. + * Uses IndexedDB + Web Crypto (non-extractable AES-256-GCM key). + * Falls back to localStorage when SubtleCrypto is unavailable. + * + * @example + * ```typescript + * import { SecureStorage } from '@bytelyst/secure-storage-web'; + * + * const storage = new SecureStorage('com.myapp'); + * await storage.set('auth_token', 'eyJhbGci...'); + * const token = await storage.get('auth_token'); + * await storage.delete('auth_token'); + * ``` + */ + +export { SecureStorage } from './secure-storage.js'; diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts new file mode 100644 index 0000000..99e2c80 --- /dev/null +++ b/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { SecureStorage } from './secure-storage.js'; + +describe('SecureStorage', () => { + let storage: SecureStorage; + + beforeEach(async () => { + storage = new SecureStorage('com.test.app'); + await storage.clear(); + }); + + // ── Basic CRUD ────────────────────────────────── + + it('set and get', async () => { + await storage.set('token', 'abc123'); + const val = await storage.get('token'); + expect(val).toBe('abc123'); + }); + + it('get returns null for missing key', async () => { + const val = await storage.get('nonexistent'); + expect(val).toBeNull(); + }); + + it('overwrite existing key', async () => { + await storage.set('token', 'first'); + await storage.set('token', 'second'); + const val = await storage.get('token'); + expect(val).toBe('second'); + }); + + it('delete removes value', async () => { + await storage.set('token', 'abc'); + await storage.delete('token'); + const val = await storage.get('token'); + expect(val).toBeNull(); + }); + + it('delete non-existent key does not throw', async () => { + await expect(storage.delete('nope')).resolves.toBeUndefined(); + }); + + // ── Clear ─────────────────────────────────────── + + it('clear removes all values', async () => { + await storage.set('a', '1'); + await storage.set('b', '2'); + await storage.clear(); + expect(await storage.get('a')).toBeNull(); + expect(await storage.get('b')).toBeNull(); + }); + + // ── Has ───────────────────────────────────────── + + it('has returns true for existing key', async () => { + await storage.set('x', 'val'); + expect(await storage.has('x')).toBe(true); + }); + + it('has returns false for missing key', async () => { + expect(await storage.has('missing')).toBe(false); + }); + + // ── Keys ──────────────────────────────────────── + + it('keys lists stored keys', async () => { + await storage.set('alpha', '1'); + await storage.set('beta', '2'); + const keys = await storage.keys(); + expect(keys.sort()).toEqual(['alpha', 'beta']); + }); + + it('keys returns empty for fresh storage', async () => { + const keys = await storage.keys(); + expect(keys).toEqual([]); + }); + + // ── Namespace isolation ───────────────────────── + + it('different namespaces are isolated', async () => { + const s1 = new SecureStorage('ns1'); + const s2 = new SecureStorage('ns2'); + await s1.set('key', 'from-s1'); + await s2.set('key', 'from-s2'); + expect(await s1.get('key')).toBe('from-s1'); + expect(await s2.get('key')).toBe('from-s2'); + }); + + // ── Data types ────────────────────────────────── + + it('empty string', async () => { + await storage.set('empty', ''); + const val = await storage.get('empty'); + expect(val).toBe(''); + }); + + it('unicode values', async () => { + const text = 'こんにちは世界 🌍 مرحبا Ñoño'; + await storage.set('unicode', text); + expect(await storage.get('unicode')).toBe(text); + }); + + it('large value', async () => { + const big = 'X'.repeat(100_000); + await storage.set('big', big); + expect(await storage.get('big')).toBe(big); + }); + + it('JSON string value', async () => { + const json = JSON.stringify({ token: 'abc', user: { id: 1 } }); + await storage.set('session', json); + const parsed = JSON.parse((await storage.get('session'))!); + expect(parsed.token).toBe('abc'); + expect(parsed.user.id).toBe(1); + }); + + // ── isSupported ───────────────────────────────── + + it('isSupported returns true in test environment', () => { + expect(SecureStorage.isSupported()).toBe(true); + }); +}); diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.ts new file mode 100644 index 0000000..99832ac --- /dev/null +++ b/vendor/bytelyst/secure-storage-web/src/secure-storage.ts @@ -0,0 +1,259 @@ +/** + * @bytelyst/secure-storage-web — Encrypted web storage + * + * IndexedDB-backed key-value storage with a non-extractable AES-256-GCM + * CryptoKey managed by Web Crypto. The encryption key never leaves the + * browser's crypto subsystem. + * + * Falls back to localStorage if IndexedDB or SubtleCrypto is unavailable. + */ + +const DB_NAME = 'bytelyst_secure_storage'; +const DB_VERSION = 1; +const KEY_STORE = 'crypto_keys'; +const DATA_STORE = 'encrypted_data'; +const MASTER_KEY_ID = '__master_key__'; +const ALGORITHM = 'AES-GCM'; +const KEY_SIZE = 256; +const IV_BYTES = 12; +const TAG_BITS = 128; + +// ── IndexedDB helpers ─────────────────────────────── + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(KEY_STORE)) { + db.createObjectStore(KEY_STORE); + } + if (!db.objectStoreNames.contains(DATA_STORE)) { + db.createObjectStore(DATA_STORE); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function idbGet(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); +} + +function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbDelete(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbClear(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).clear(); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +function idbGetAllKeys(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).getAllKeys(); + req.onsuccess = () => resolve(req.result as string[]); + req.onerror = () => reject(req.error); + }); +} + +// ── Stored encrypted value format ─────────────────── + +interface StoredValue { + /** Ciphertext bytes. */ + ct: ArrayBuffer; + /** Initialization vector. */ + iv: ArrayBuffer; +} + +// ── SecureStorage class ───────────────────────────── + +/** + * Encrypted key-value storage backed by IndexedDB + Web Crypto. + * + * The AES-256-GCM master key is generated once and stored as a + * **non-extractable** CryptoKey in IndexedDB. Values are encrypted + * before storage and decrypted on retrieval. The raw key material + * never leaves the browser's crypto subsystem. + * + * Falls back to plaintext localStorage if IndexedDB or SubtleCrypto + * is not available (e.g., legacy browsers, server-side rendering). + * + * @example + * ```typescript + * const storage = new SecureStorage('com.myapp'); + * await storage.set('auth_token', 'eyJhbGci...'); + * const token = await storage.get('auth_token'); + * await storage.delete('auth_token'); + * await storage.clear(); + * ``` + */ +export class SecureStorage { + private readonly namespace: string; + private db: IDBDatabase | null = null; + private masterKey: CryptoKey | null = null; + private readonly fallback: boolean; + + constructor(namespace: string) { + this.namespace = namespace; + this.fallback = !SecureStorage.isSupported(); + } + + /** Check if IndexedDB + SubtleCrypto are available. */ + static isSupported(): boolean { + return ( + typeof indexedDB !== 'undefined' && + typeof globalThis !== 'undefined' && + !!globalThis.crypto?.subtle + ); + } + + // ── Public API ────────────────────────────────── + + /** Store an encrypted value. */ + async set(key: string, value: string): Promise { + if (this.fallback) { + localStorage.setItem(this.ns(key), value); + return; + } + const { db, masterKey } = await this.ensureReady(); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const encoded = new TextEncoder().encode(value); + + const ciphertext = await crypto.subtle.encrypt( + { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS }, + masterKey, + encoded.buffer as ArrayBuffer + ); + + const stored: StoredValue = { ct: ciphertext, iv: iv.buffer as ArrayBuffer }; + await idbPut(db, DATA_STORE, this.ns(key), stored); + } + + /** Retrieve and decrypt a value. Returns `null` if not found. */ + async get(key: string): Promise { + if (this.fallback) { + return localStorage.getItem(this.ns(key)); + } + const { db, masterKey } = await this.ensureReady(); + const stored = await idbGet(db, DATA_STORE, this.ns(key)); + if (!stored) return null; + + const plaintext = await crypto.subtle.decrypt( + { name: ALGORITHM, iv: stored.iv, tagLength: TAG_BITS }, + masterKey, + stored.ct + ); + + return new TextDecoder().decode(plaintext); + } + + /** Delete a stored value. */ + async delete(key: string): Promise { + if (this.fallback) { + localStorage.removeItem(this.ns(key)); + return; + } + const { db } = await this.ensureReady(); + await idbDelete(db, DATA_STORE, this.ns(key)); + } + + /** Clear all stored values (keeps the master key). */ + async clear(): Promise { + if (this.fallback) { + const prefix = this.ns(''); + const toRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) toRemove.push(k); + } + toRemove.forEach(k => localStorage.removeItem(k)); + return; + } + const { db } = await this.ensureReady(); + await idbClear(db, DATA_STORE); + } + + /** Check if a key exists. */ + async has(key: string): Promise { + if (this.fallback) { + return localStorage.getItem(this.ns(key)) !== null; + } + const { db } = await this.ensureReady(); + const stored = await idbGet(db, DATA_STORE, this.ns(key)); + return stored !== undefined; + } + + /** List all stored keys (without namespace prefix). */ + async keys(): Promise { + if (this.fallback) { + const prefix = this.ns(''); + const result: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k && k.startsWith(prefix)) result.push(k.slice(prefix.length)); + } + return result; + } + const { db } = await this.ensureReady(); + const allKeys = await idbGetAllKeys(db, DATA_STORE); + const prefix = this.ns(''); + return allKeys.filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); + } + + // ── Internal ──────────────────────────────────── + + private ns(key: string): string { + return `${this.namespace}:${key}`; + } + + private async ensureReady(): Promise<{ db: IDBDatabase; masterKey: CryptoKey }> { + if (this.db && this.masterKey) { + return { db: this.db, masterKey: this.masterKey }; + } + + const db = await openDb(); + this.db = db; + + // Try to load existing master key + let masterKey = await idbGet(db, KEY_STORE, MASTER_KEY_ID); + + if (!masterKey) { + // Generate a new non-extractable master key + masterKey = await crypto.subtle.generateKey( + { name: ALGORITHM, length: KEY_SIZE }, + false, // non-extractable — key material never leaves crypto subsystem + ['encrypt', 'decrypt'] + ); + await idbPut(db, KEY_STORE, MASTER_KEY_ID, masterKey); + } + + this.masterKey = masterKey; + return { db, masterKey }; + } +} diff --git a/vendor/bytelyst/secure-storage-web/tsconfig.json b/vendor/bytelyst/secure-storage-web/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/secure-storage-web/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/vendor/bytelyst/speech/package.json b/vendor/bytelyst/speech/package.json new file mode 100644 index 0000000..f4e405e --- /dev/null +++ b/vendor/bytelyst/speech/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/speech", + "version": "0.1.5", + "description": "Cloud-agnostic speech-to-text abstraction for the ByteLyst ecosystem", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "peerDependencies": {}, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/speech/src/__tests__/speech.test.ts b/vendor/bytelyst/speech/src/__tests__/speech.test.ts new file mode 100644 index 0000000..b3b8569 --- /dev/null +++ b/vendor/bytelyst/speech/src/__tests__/speech.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { createSpeechTranscriber, MockSpeechTranscriber } from '../index.js'; +import type { SpeechTranscriber } from '../index.js'; + +describe('@bytelyst/speech', () => { + describe('MockSpeechTranscriber', () => { + it('implements SpeechTranscriber interface', () => { + const transcriber: SpeechTranscriber = new MockSpeechTranscriber(); + expect(transcriber.isActive).toBe(false); + }); + + it('start/stop lifecycle works', () => { + const transcriber = new MockSpeechTranscriber(); + expect(transcriber.isActive).toBe(false); + + transcriber.start(); + expect(transcriber.isActive).toBe(true); + + const result = transcriber.stop(); + expect(transcriber.isActive).toBe(false); + expect(result).toBe('Hello, this is a mock transcript.'); + }); + + it('returns configurable mock transcript', () => { + const transcriber = new MockSpeechTranscriber(); + transcriber.mockTranscript = 'Custom transcript'; + transcriber.mockConfidence = 0.8; + + transcriber.start(); + const result = transcriber.stop(); + expect(result).toBe('Custom transcript'); + }); + + it('calls onFinal callback on stop', () => { + const transcriber = new MockSpeechTranscriber(); + let receivedText = ''; + let receivedConfidence = 0; + + transcriber.onFinal((text, confidence) => { + receivedText = text; + receivedConfidence = confidence; + }); + + transcriber.start(); + transcriber.stop(); + + expect(receivedText).toBe('Hello, this is a mock transcript.'); + expect(receivedConfidence).toBe(0.95); + }); + + it('calls onPartial callback on pushAudio', () => { + const transcriber = new MockSpeechTranscriber(); + let partialText = ''; + + transcriber.onPartial(text => { + partialText = text; + }); + + transcriber.start(); + transcriber.pushAudio(new Uint8Array(100)); + expect(partialText).toBe('[Recording...]'); + }); + + it('ignores pushAudio when not active', () => { + const transcriber = new MockSpeechTranscriber(); + let called = false; + transcriber.onPartial(() => { + called = true; + }); + + transcriber.pushAudio(new Uint8Array(100)); + expect(called).toBe(false); + }); + + it('setVocabulary stores phrases', () => { + const transcriber = new MockSpeechTranscriber(); + transcriber.setVocabulary(['hello', 'world']); + expect(transcriber.getVocabulary()).toEqual(['hello', 'world']); + }); + + it('simulateError calls onError callback', () => { + const transcriber = new MockSpeechTranscriber(); + let errorMsg = ''; + transcriber.onError(err => { + errorMsg = err.message; + }); + + transcriber.simulateError('connection lost'); + expect(errorMsg).toBe('connection lost'); + }); + }); + + describe('createSpeechTranscriber', () => { + it('creates mock transcriber by default', () => { + const transcriber = createSpeechTranscriber({ provider: 'mock' }); + expect(transcriber).toBeInstanceOf(MockSpeechTranscriber); + }); + + it('throws for azure provider (requires native implementation)', () => { + expect(() => createSpeechTranscriber({ provider: 'azure' })).toThrow( + 'platform-specific implementation' + ); + }); + + it('throws for whisper provider (requires native implementation)', () => { + expect(() => createSpeechTranscriber({ provider: 'whisper' })).toThrow( + 'platform-specific implementation' + ); + }); + + it('throws for unknown provider', () => { + expect(() => createSpeechTranscriber({ provider: 'nonexistent' as 'azure' })).toThrow( + 'Unknown speech provider' + ); + }); + }); +}); diff --git a/vendor/bytelyst/speech/src/factory.ts b/vendor/bytelyst/speech/src/factory.ts new file mode 100644 index 0000000..04dcbe4 --- /dev/null +++ b/vendor/bytelyst/speech/src/factory.ts @@ -0,0 +1,54 @@ +/** + * Factory function for creating speech transcribers. + * + * Auto-detects provider from SPEECH_PROVIDER env var, or falls back + * to 'azure' if AZURE_SPEECH_KEY is set, else 'mock'. + */ + +import type { SpeechConfig, SpeechTranscriber } from './types.js'; +import { MockSpeechTranscriber } from './providers/mock.js'; + +/** + * Create a speech transcriber based on config or env vars. + * + * For Azure and Whisper providers, consumers must provide their own + * platform-specific implementations (Python azure_stt.py / whisper_stt.py, + * Swift AzureSpeechTranscriber, etc.). This factory handles the mock + * provider for testing and serves as the registry point for future + * TS-native providers. + */ +export function createSpeechTranscriber(config?: Partial): SpeechTranscriber { + const provider = config?.provider ?? detectProvider(); + + switch (provider) { + case 'mock': + return new MockSpeechTranscriber(); + case 'azure': + case 'whisper': + case 'google': + case 'deepgram': + throw new Error( + `Speech provider '${provider}' requires a platform-specific implementation. ` + + `Use the Python SpeechTranscriber ABC (src/audio/speech_types.py) or ` + + `Swift SpeechTranscriberProtocol for native providers.` + ); + default: + throw new Error(`Unknown speech provider: ${provider}`); + } +} + +function detectProvider(): SpeechConfig['provider'] { + const explicit = (process.env.SPEECH_PROVIDER || '').toLowerCase(); + if ( + explicit === 'azure' || + explicit === 'whisper' || + explicit === 'google' || + explicit === 'deepgram' || + explicit === 'mock' + ) { + return explicit; + } + return 'mock'; +} + +export { MockSpeechTranscriber } from './providers/mock.js'; diff --git a/vendor/bytelyst/speech/src/index.ts b/vendor/bytelyst/speech/src/index.ts new file mode 100644 index 0000000..dd38008 --- /dev/null +++ b/vendor/bytelyst/speech/src/index.ts @@ -0,0 +1,10 @@ +export type { + SpeechTranscriber, + SpeechConfig, + TranscriptionResult, + PartialCallback, + FinalCallback, + ErrorCallback, +} from './types.js'; + +export { createSpeechTranscriber, MockSpeechTranscriber } from './factory.js'; diff --git a/vendor/bytelyst/speech/src/providers/mock.ts b/vendor/bytelyst/speech/src/providers/mock.ts new file mode 100644 index 0000000..a730f88 --- /dev/null +++ b/vendor/bytelyst/speech/src/providers/mock.ts @@ -0,0 +1,70 @@ +/** + * Mock speech transcriber for testing. + * + * Returns configurable responses without requiring any speech SDK. + */ + +import type { ErrorCallback, FinalCallback, PartialCallback, SpeechTranscriber } from '../types.js'; + +export class MockSpeechTranscriber implements SpeechTranscriber { + private _isActive = false; + private _partialCb: PartialCallback | null = null; + private _finalCb: FinalCallback | null = null; + private _errorCb: ErrorCallback | null = null; + private _vocabulary: string[] = []; + + /** Configurable mock response. */ + public mockTranscript = 'Hello, this is a mock transcript.'; + public mockConfidence = 0.95; + + get isActive(): boolean { + return this._isActive; + } + + start(): void { + this._isActive = true; + } + + stop(): string { + this._isActive = false; + if (this._finalCb) { + this._finalCb(this.mockTranscript, this.mockConfidence); + } + return this.mockTranscript; + } + + pushAudio(_data: ArrayBuffer | Uint8Array): void { + if (!this._isActive) return; + if (this._partialCb) { + this._partialCb('[Recording...]'); + } + } + + onPartial(callback: PartialCallback): void { + this._partialCb = callback; + } + + onFinal(callback: FinalCallback): void { + this._finalCb = callback; + } + + onError(callback: ErrorCallback): void { + this._errorCb = callback; + } + + setVocabulary(phrases: string[]): void { + this._vocabulary = phrases; + } + + /** Simulate an error (for testing). */ + simulateError(message: string): void { + if (this._errorCb) { + this._errorCb(new Error(message)); + } + } + + /** Get the configured vocabulary (for test assertions). */ + getVocabulary(): string[] { + return this._vocabulary; + } +} diff --git a/vendor/bytelyst/speech/src/types.ts b/vendor/bytelyst/speech/src/types.ts new file mode 100644 index 0000000..4dd72b6 --- /dev/null +++ b/vendor/bytelyst/speech/src/types.ts @@ -0,0 +1,84 @@ +/** + * Cloud-agnostic speech-to-text abstraction. + * + * Providers implement SpeechTranscriber to wrap platform-specific SDKs + * (Azure Speech, Google Cloud Speech, Deepgram, local Whisper, etc.) + * behind a unified push-audio + callback interface. + */ + +/** Callback for partial (interim) transcription results. */ +export type PartialCallback = (text: string) => void; + +/** Callback for final (committed) transcription results. */ +export type FinalCallback = (text: string, confidence: number) => void; + +/** Callback for transcription errors. */ +export type ErrorCallback = (error: Error) => void; + +/** + * Cloud-agnostic streaming speech-to-text interface. + * + * Audio is pushed in chunks (PCM 16-bit, 16kHz, mono by convention). + * Results are delivered asynchronously via callbacks. + */ +export interface SpeechTranscriber { + /** Start continuous recognition for the given language. */ + start(language?: string): Promise | void; + + /** Stop recognition and finalize any pending results. */ + stop(): Promise | string; + + /** Push raw audio data (PCM 16-bit, 16kHz, mono). */ + pushAudio(data: ArrayBuffer | Uint8Array): void; + + /** Register callback for partial (interim) results. */ + onPartial(callback: PartialCallback): void; + + /** Register callback for final (committed) results. */ + onFinal(callback: FinalCallback): void; + + /** Register callback for errors. */ + onError(callback: ErrorCallback): void; + + /** Set custom vocabulary / phrase hints for better accuracy. */ + setVocabulary?(phrases: string[]): void; + + /** Whether the transcriber is currently active. */ + readonly isActive: boolean; +} + +/** Configuration for creating a speech transcriber. */ +export interface SpeechConfig { + /** Provider type. */ + provider: 'azure' | 'whisper' | 'google' | 'deepgram' | 'mock'; + + /** Azure-specific: speech service key. */ + speechKey?: string; + + /** Azure-specific: speech service region. */ + speechRegion?: string; + + /** Whisper-specific: model size (tiny, base, small, medium, large). */ + whisperModelSize?: string; + + /** Default language (BCP-47 code, e.g. 'en-US'). */ + language?: string; + + /** Custom vocabulary phrases for better accuracy. */ + vocabulary?: string[]; +} + +/** Result of a completed transcription session. */ +export interface TranscriptionResult { + /** Full transcribed text. */ + text: string; + + /** Confidence score (0-1), if available. */ + confidence?: number; + + /** Duration of audio in seconds. */ + durationSeconds?: number; + + /** Which provider produced this result. */ + provider: string; +} diff --git a/vendor/bytelyst/speech/tsconfig.json b/vendor/bytelyst/speech/tsconfig.json new file mode 100644 index 0000000..81f2cd1 --- /dev/null +++ b/vendor/bytelyst/speech/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/storage/package.json b/vendor/bytelyst/storage/package.json new file mode 100644 index 0000000..4e46626 --- /dev/null +++ b/vendor/bytelyst/storage/package.json @@ -0,0 +1,33 @@ +{ + "name": "@bytelyst/storage", + "version": "0.1.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "dependencies": { + "@azure/storage-blob": ">=12.0.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts b/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts new file mode 100644 index 0000000..56f0c45 --- /dev/null +++ b/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for MemoryStorageProvider. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryStorageProvider } from '../providers/memory.js'; +import type { StorageBucket } from '../types.js'; + +describe('MemoryStorageProvider', () => { + let provider: MemoryStorageProvider; + let bucket: StorageBucket; + + beforeEach(() => { + provider = new MemoryStorageProvider(); + bucket = provider.getBucket('test-bucket'); + }); + + it('isHealthy returns true', async () => { + expect(await provider.isHealthy()).toBe(true); + }); + + it('returns same bucket instance', () => { + const b1 = provider.getBucket('x'); + const b2 = provider.getBucket('x'); + expect(b1).toBe(b2); + }); + + describe('bucket operations', () => { + it('upload + download', async () => { + await bucket.upload('file.txt', 'hello world', { contentType: 'text/plain' }); + const data = await bucket.download('file.txt'); + expect(data.toString()).toBe('hello world'); + }); + + it('upload returns metadata', async () => { + const meta = await bucket.upload('file.txt', Buffer.from('test'), { + contentType: 'text/plain', + metadata: { foo: 'bar' }, + }); + expect(meta.key).toBe('file.txt'); + expect(meta.size).toBe(4); + expect(meta.contentType).toBe('text/plain'); + expect(meta.metadata?.foo).toBe('bar'); + }); + + it('download throws for missing blob', async () => { + await expect(bucket.download('missing')).rejects.toThrow('not found'); + }); + + it('exists returns true/false', async () => { + expect(await bucket.exists('file.txt')).toBe(false); + await bucket.upload('file.txt', 'data'); + expect(await bucket.exists('file.txt')).toBe(true); + }); + + it('delete removes blob', async () => { + await bucket.upload('file.txt', 'data'); + await bucket.delete('file.txt'); + expect(await bucket.exists('file.txt')).toBe(false); + }); + + it('list returns all blobs', async () => { + await bucket.upload('a/1.txt', 'data'); + await bucket.upload('a/2.txt', 'data'); + await bucket.upload('b/1.txt', 'data'); + + const all = await bucket.list(); + expect(all).toHaveLength(3); + + const prefixed = await bucket.list('a/'); + expect(prefixed).toHaveLength(2); + }); + + it('getSignedUrl returns memory URL', async () => { + const url = await bucket.getSignedUrl('file.txt'); + expect(url).toContain('memory://'); + expect(url).toContain('file.txt'); + }); + }); +}); diff --git a/vendor/bytelyst/storage/src/factory.ts b/vendor/bytelyst/storage/src/factory.ts new file mode 100644 index 0000000..c37c2e4 --- /dev/null +++ b/vendor/bytelyst/storage/src/factory.ts @@ -0,0 +1,52 @@ +/** + * Storage provider factory. + * + * Creates a StorageProvider based on STORAGE_PROVIDER env var or explicit type. + * Defaults to 'azure' for backward compatibility. + */ + +import { MemoryStorageProvider } from './providers/memory.js'; +import type { StorageProvider, StorageProviderType } from './types.js'; + +let _provider: StorageProvider | null = null; + +/** + * Get the singleton storage provider. + */ +export async function getStorage(): Promise { + if (!_provider) { + const providerType = (process.env.STORAGE_PROVIDER || 'azure') as StorageProviderType; + _provider = await createStorageProvider(providerType); + } + return _provider; +} + +/** + * Create a storage provider by type. + */ +export async function createStorageProvider(type: StorageProviderType): Promise { + switch (type) { + case 'azure': { + const { AzureBlobStorageProvider } = await import('./providers/azure-blob.js'); + return new AzureBlobStorageProvider(); + } + case 'memory': + return new MemoryStorageProvider(); + default: + throw new Error(`Unknown STORAGE_PROVIDER: '${type}'. Valid: azure, memory`); + } +} + +/** + * Set the singleton storage provider (for testing). + */ +export function setStorage(provider: StorageProvider): void { + _provider = provider; +} + +/** + * @internal + */ +export function _resetStorage(): void { + _provider = null; +} diff --git a/vendor/bytelyst/storage/src/index.ts b/vendor/bytelyst/storage/src/index.ts new file mode 100644 index 0000000..e704073 --- /dev/null +++ b/vendor/bytelyst/storage/src/index.ts @@ -0,0 +1,12 @@ +export type { + StorageProvider, + StorageBucket, + UploadOptions, + SignedUrlOptions, + BlobMeta, + StorageProviderType, +} from './types.js'; + +export { getStorage, createStorageProvider, setStorage, _resetStorage } from './factory.js'; +export { AzureBlobStorageProvider, type AzureBlobProviderConfig } from './providers/azure-blob.js'; +export { MemoryStorageProvider } from './providers/memory.js'; diff --git a/vendor/bytelyst/storage/src/providers/azure-blob.ts b/vendor/bytelyst/storage/src/providers/azure-blob.ts new file mode 100644 index 0000000..86d3a9b --- /dev/null +++ b/vendor/bytelyst/storage/src/providers/azure-blob.ts @@ -0,0 +1,207 @@ +/** + * Azure Blob Storage provider. + * + * Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface. + */ + +import type { + BlobMeta, + SignedUrlOptions, + StorageBucket, + StorageProvider, + UploadOptions, +} from '../types.js'; + +export interface AzureBlobProviderConfig { + connectionString?: string; + accountName?: string; + accountKey?: string; + blobEndpoint?: string; + publicBlobEndpoint?: string; +} + +function parseConnectionString(connectionString: string): Partial { + const parts = new Map(); + + for (const segment of connectionString.split(';')) { + const [key, ...rest] = segment.split('='); + if (!key || rest.length === 0) continue; + parts.set(key, rest.join('=')); + } + + return { + accountName: parts.get('AccountName'), + accountKey: parts.get('AccountKey'), + blobEndpoint: parts.get('BlobEndpoint'), + }; +} + +export class AzureBlobStorageProvider implements StorageProvider { + private client: unknown = null; + private config: AzureBlobProviderConfig; + private buckets = new Map(); + + constructor(config?: AzureBlobProviderConfig) { + const envConfig = config ?? { + connectionString: process.env.AZURE_BLOB_CONNECTION_STRING, + accountName: process.env.AZURE_BLOB_ACCOUNT_NAME, + accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY, + publicBlobEndpoint: process.env.AZURE_BLOB_PUBLIC_ENDPOINT, + }; + + const parsed = envConfig.connectionString + ? parseConnectionString(envConfig.connectionString) + : undefined; + + this.config = { + ...parsed, + ...envConfig, + accountName: envConfig.accountName ?? parsed?.accountName, + accountKey: envConfig.accountKey ?? parsed?.accountKey, + blobEndpoint: envConfig.blobEndpoint ?? parsed?.blobEndpoint, + }; + } + + private async getClient() { + if (!this.client) { + const { BlobServiceClient } = await import('@azure/storage-blob'); + if (this.config.connectionString) { + this.client = BlobServiceClient.fromConnectionString(this.config.connectionString); + } else if (this.config.accountName && this.config.accountKey) { + const { StorageSharedKeyCredential } = await import('@azure/storage-blob'); + const cred = new StorageSharedKeyCredential( + this.config.accountName, + this.config.accountKey + ); + const endpoint = + this.config.blobEndpoint ?? `https://${this.config.accountName}.blob.core.windows.net`; + this.client = new BlobServiceClient(endpoint, cred); + } else { + throw new Error( + 'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY' + ); + } + } + return this.client as import('@azure/storage-blob').BlobServiceClient; + } + + getBucket(name: string): StorageBucket { + let bucket = this.buckets.get(name); + if (!bucket) { + bucket = new AzureBlobBucket(name, () => this.getClient(), this.config); + this.buckets.set(name, bucket); + } + return bucket; + } + + async isHealthy(): Promise { + try { + const client = await this.getClient(); + // List one container to verify connectivity + const iter = client.listContainers(); + await iter.next(); + return true; + } catch { + return false; + } + } +} + +class AzureBlobBucket implements StorageBucket { + constructor( + private containerName: string, + private getClient: () => Promise, + private config: AzureBlobProviderConfig + ) {} + + private async containerClient() { + const client = await this.getClient(); + const container = client.getContainerClient(this.containerName); + await container.createIfNotExists(); + return container; + } + + async upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise { + const container = await this.containerClient(); + const blockBlob = container.getBlockBlobClient(key); + const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); + await blockBlob.upload(buf, buf.length, { + blobHTTPHeaders: { blobContentType: options?.contentType }, + metadata: options?.metadata, + }); + return { + key, + size: buf.length, + contentType: options?.contentType, + lastModified: new Date(), + metadata: options?.metadata, + }; + } + + async download(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + const response = await blob.downloadToBuffer(); + return response; + } + + async delete(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + await blob.deleteIfExists(); + } + + async exists(key: string): Promise { + const container = await this.containerClient(); + const blob = container.getBlobClient(key); + return blob.exists(); + } + + async list(prefix?: string): Promise { + const container = await this.containerClient(); + const results: BlobMeta[] = []; + for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) { + results.push({ + key: blob.name, + size: blob.properties.contentLength ?? undefined, + contentType: blob.properties.contentType ?? undefined, + lastModified: blob.properties.lastModified, + }); + } + return results; + } + + async getSignedUrl(key: string, options?: SignedUrlOptions): Promise { + const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } = + await import('@azure/storage-blob'); + + if (!this.config.accountName || !this.config.accountKey) { + throw new Error('Signed URLs require accountName + accountKey'); + } + + const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey); + const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000); + const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r'); + + const sas = generateBlobSASQueryParameters( + { + containerName: this.containerName, + blobName: key, + permissions, + expiresOn, + }, + cred + ); + + const baseUrl = + this.config.publicBlobEndpoint ?? + this.config.blobEndpoint ?? + `https://${this.config.accountName}.blob.core.windows.net`; + + return `${baseUrl.replace(/\/$/, '')}/${this.containerName}/${key}?${sas.toString()}`; + } +} diff --git a/vendor/bytelyst/storage/src/providers/memory.ts b/vendor/bytelyst/storage/src/providers/memory.ts new file mode 100644 index 0000000..4bb2710 --- /dev/null +++ b/vendor/bytelyst/storage/src/providers/memory.ts @@ -0,0 +1,83 @@ +/** + * In-memory storage provider — for testing and local dev. + */ + +import type { + BlobMeta, + SignedUrlOptions, + StorageBucket, + StorageProvider, + UploadOptions, +} from '../types.js'; + +export class MemoryStorageProvider implements StorageProvider { + private buckets = new Map(); + + getBucket(name: string): StorageBucket { + let bucket = this.buckets.get(name); + if (!bucket) { + bucket = new MemoryBucket(name); + this.buckets.set(name, bucket); + } + return bucket; + } + + async isHealthy(): Promise { + return true; + } + + clear(): void { + this.buckets.clear(); + } +} + +class MemoryBucket implements StorageBucket { + private blobs = new Map(); + + constructor(private name: string) {} + + async upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise { + const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); + const meta: BlobMeta = { + key, + size: buf.length, + contentType: options?.contentType ?? 'application/octet-stream', + lastModified: new Date(), + metadata: options?.metadata, + }; + this.blobs.set(key, { data: buf, meta }); + return meta; + } + + async download(key: string): Promise { + const entry = this.blobs.get(key); + if (!entry) throw new Error(`Blob '${key}' not found in bucket '${this.name}'`); + return entry.data; + } + + async delete(key: string): Promise { + this.blobs.delete(key); + } + + async exists(key: string): Promise { + return this.blobs.has(key); + } + + async list(prefix?: string): Promise { + const results: BlobMeta[] = []; + for (const [key, entry] of this.blobs) { + if (!prefix || key.startsWith(prefix)) { + results.push(entry.meta); + } + } + return results; + } + + async getSignedUrl(key: string, _options?: SignedUrlOptions): Promise { + return `memory://${this.name}/${key}?signed=true`; + } +} diff --git a/vendor/bytelyst/storage/src/testing.ts b/vendor/bytelyst/storage/src/testing.ts new file mode 100644 index 0000000..c3cecff --- /dev/null +++ b/vendor/bytelyst/storage/src/testing.ts @@ -0,0 +1,24 @@ +/** + * Test helpers for @bytelyst/storage. + */ + +import { setStorage, _resetStorage } from './factory.js'; +import { MemoryStorageProvider } from './providers/memory.js'; + +let _testProvider: MemoryStorageProvider | null = null; + +export function setTestStorageProvider(): MemoryStorageProvider { + _testProvider = new MemoryStorageProvider(); + setStorage(_testProvider); + return _testProvider; +} + +export function clearTestStorage(): void { + _testProvider?.clear(); +} + +export function resetTestStorage(): void { + _testProvider?.clear(); + _testProvider = null; + _resetStorage(); +} diff --git a/vendor/bytelyst/storage/src/types.ts b/vendor/bytelyst/storage/src/types.ts new file mode 100644 index 0000000..b1f7211 --- /dev/null +++ b/vendor/bytelyst/storage/src/types.ts @@ -0,0 +1,59 @@ +/** + * Cloud-agnostic storage interfaces. + * + * Provides StorageProvider and StorageBucket abstractions + * that work with Azure Blob, S3, R2, or in-memory storage. + */ + +export interface StorageProvider { + /** Get or create a bucket/container. */ + getBucket(name: string): StorageBucket; + + /** Check if storage is configured and reachable. */ + isHealthy(): Promise; +} + +export interface StorageBucket { + /** Upload a blob. */ + upload( + key: string, + data: Buffer | Uint8Array | string, + options?: UploadOptions + ): Promise; + + /** Download a blob as a Buffer. */ + download(key: string): Promise; + + /** Delete a blob. */ + delete(key: string): Promise; + + /** Check if a blob exists. */ + exists(key: string): Promise; + + /** List blobs with optional prefix. */ + list(prefix?: string): Promise; + + /** Get a signed URL for temporary access. */ + getSignedUrl(key: string, options?: SignedUrlOptions): Promise; +} + +export interface UploadOptions { + contentType?: string; + metadata?: Record; +} + +export interface SignedUrlOptions { + /** Expiry in seconds (default 3600). */ + expiresIn?: number; + permissions?: 'read' | 'write'; +} + +export interface BlobMeta { + key: string; + size?: number; + contentType?: string; + lastModified?: Date; + metadata?: Record; +} + +export type StorageProviderType = 'azure' | 'memory'; diff --git a/vendor/bytelyst/storage/tsconfig.json b/vendor/bytelyst/storage/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/storage/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/subscription-client/package.json b/vendor/bytelyst/subscription-client/package.json new file mode 100644 index 0000000..b5dce4a --- /dev/null +++ b/vendor/bytelyst/subscription-client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/subscription-client", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/subscription-client/src/client.test.ts b/vendor/bytelyst/subscription-client/src/client.test.ts new file mode 100644 index 0000000..33b8b8e --- /dev/null +++ b/vendor/bytelyst/subscription-client/src/client.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createSubscriptionClient } from './client.js'; +import type { SubscriptionDoc, PlanConfig } from './types.js'; + +const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + userId: 'user-1', + getAccessToken: () => 'test-token', +}; + +function mockSub(overrides?: Partial): SubscriptionDoc { + return { + id: 'sub-1', + productId: 'testapp', + userId: 'user-1', + plan: 'pro', + status: 'active', + currentPeriodStart: '2026-01-01T00:00:00Z', + currentPeriodEnd: '2026-12-31T23:59:59Z', + cancelAtPeriodEnd: false, + monthlyPrice: 999, + tokensIncluded: 10000, + tokensUsed: 500, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function mockPlan(overrides?: Partial): PlanConfig { + return { + id: 'plan-1', + productId: 'testapp', + name: 'pro', + displayName: 'Pro', + price: 999, + tokens: 10000, + words: 0, + dictations: 0, + features: ['ai_coaching', 'advanced_stats'], + active: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('createSubscriptionClient', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should get subscription by userId', async () => { + const sub = mockSub(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(sub), + }) + ); + + const client = createSubscriptionClient(baseConfig); + const result = await client.getMySubscription(); + + expect(result).toEqual(sub); + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/subscriptions/user-1', + expect.any(Object) + ); + }); + + it('should return null for 404 on getMySubscription', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + ); + + const client = createSubscriptionClient(baseConfig); + const result = await client.getMySubscription(); + expect(result).toBeNull(); + }); + + it('should unwrap .plans from getPlans response', async () => { + const plans = [mockPlan(), mockPlan({ name: 'free', displayName: 'Free', features: [] })]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ plans }), + }) + ); + + const client = createSubscriptionClient(baseConfig); + const result = await client.getPlans(); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('pro'); + }); + + it('should start a trial', async () => { + const sub = mockSub({ status: 'trialing' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(sub), + }) + ); + + const client = createSubscriptionClient(baseConfig); + const result = await client.startTrial(); + expect(result.status).toBe('trialing'); + }); + + it('should cancel subscription', async () => { + const sub = mockSub({ cancelAtPeriodEnd: true }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(sub), + }) + ); + + const client = createSubscriptionClient(baseConfig); + const result = await client.cancelSubscription(); + expect(result.cancelAtPeriodEnd).toBe(true); + }); + + it('should report isPro correctly from cache', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSub({ plan: 'pro', status: 'active' })), + }) + ); + + const client = createSubscriptionClient(baseConfig); + expect(client.isPro()).toBe(false); // no cache yet + + await client.getMySubscription(); + expect(client.isPro()).toBe(true); + }); + + it('should report isPro false for free plan', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSub({ plan: 'free', status: 'active' })), + }) + ); + + const client = createSubscriptionClient(baseConfig); + await client.getMySubscription(); + expect(client.isPro()).toBe(false); + }); + + it('should check hasFeature from cached plans', async () => { + let callIndex = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callIndex++; + if (callIndex === 1) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockSub({ plan: 'pro' })), + }); + } + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + plans: [mockPlan({ name: 'pro', features: ['ai_coaching', 'advanced_stats'] })], + }), + }); + }) + ); + + const client = createSubscriptionClient(baseConfig); + await client.refresh(); + + expect(client.hasFeature('ai_coaching')).toBe(true); + expect(client.hasFeature('nonexistent')).toBe(false); + }); + + it('should report isTrialing correctly', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSub({ status: 'trialing' })), + }) + ); + + const client = createSubscriptionClient(baseConfig); + await client.getMySubscription(); + expect(client.isTrialing()).toBe(true); + }); + + it('should calculate daysRemaining', async () => { + const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSub({ currentPeriodEnd: futureDate })), + }) + ); + + const client = createSubscriptionClient(baseConfig); + await client.getMySubscription(); + const days = client.daysRemaining(); + expect(days).toBeGreaterThanOrEqual(10); + expect(days).toBeLessThanOrEqual(11); + }); + + it('should persist and restore from storage', async () => { + const store: Record = {}; + const storage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + }; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockSub()), + }) + ); + + const client1 = createSubscriptionClient({ ...baseConfig, storage }); + await client1.getMySubscription(); + + expect(store['testapp-subscription']).toBeDefined(); + + // Create new client — should restore from storage + const client2 = createSubscriptionClient({ ...baseConfig, storage }); + expect(client2.getCachedSubscription()).not.toBeNull(); + expect(client2.isPro()).toBe(true); + }); + + it('should send correct headers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ plans: [] }), + }) + ); + + const client = createSubscriptionClient(baseConfig); + await client.getPlans(); + + const fetchMock = globalThis.fetch as ReturnType; + const callHeaders = fetchMock.mock.calls[0][1].headers as Record; + expect(callHeaders['x-product-id']).toBe('testapp'); + expect(callHeaders['Authorization']).toBe('Bearer test-token'); + }); + + it('should throw on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + + const client = createSubscriptionClient(baseConfig); + await expect(client.getPlans()).rejects.toThrow('getPlans failed: 500'); + }); +}); diff --git a/vendor/bytelyst/subscription-client/src/client.ts b/vendor/bytelyst/subscription-client/src/client.ts new file mode 100644 index 0000000..0922773 --- /dev/null +++ b/vendor/bytelyst/subscription-client/src/client.ts @@ -0,0 +1,193 @@ +/** + * Browser/React Native-safe subscription client for platform-service. + * + * Wraps platform-service /subscriptions/* + /plans/* endpoints. + * Caches subscription and plans for offline reads. + * No Node.js dependencies — uses globalThis.fetch. + */ + +import type { + PlanConfig, + SubscriptionClient, + SubscriptionClientConfig, + SubscriptionDoc, +} from './types.js'; + +function generateRequestId(): string { + return typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient { + const { baseUrl, productId, userId, getAccessToken, storage } = config; + + const SUB_KEY = `${productId}-subscription`; + const PLANS_KEY = `${productId}-plans`; + + let cachedSub: SubscriptionDoc | null = null; + let cachedPlans: PlanConfig[] = []; + + // Restore from storage on creation + if (storage) { + try { + const raw = storage.getItem(SUB_KEY); + if (raw) cachedSub = JSON.parse(raw) as SubscriptionDoc; + } catch { + /* ignore */ + } + try { + const raw = storage.getItem(PLANS_KEY); + if (raw) cachedPlans = JSON.parse(raw) as PlanConfig[]; + } catch { + /* ignore */ + } + } + + function headers(): Record { + const h: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': generateRequestId(), + }; + const token = getAccessToken(); + if (token) h['Authorization'] = `Bearer ${token}`; + return h; + } + + function persistSub(sub: SubscriptionDoc | null): void { + cachedSub = sub; + if (storage) { + try { + storage.setItem(SUB_KEY, JSON.stringify(sub)); + } catch { + /* ignore */ + } + } + } + + function persistPlans(plans: PlanConfig[]): void { + cachedPlans = plans; + if (storage) { + try { + storage.setItem(PLANS_KEY, JSON.stringify(plans)); + } catch { + /* ignore */ + } + } + } + + async function getMySubscription(): Promise { + const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { + headers: headers(), + }); + if (res.status === 404) { + persistSub(null); + return null; + } + if (!res.ok) throw new Error(`getMySubscription failed: ${res.status}`); + const sub = (await res.json()) as SubscriptionDoc; + persistSub(sub); + return sub; + } + + async function getPlans(): Promise { + const res = await globalThis.fetch(`${baseUrl}/plans`, { headers: headers() }); + if (!res.ok) throw new Error(`getPlans failed: ${res.status}`); + const data = (await res.json()) as { plans: PlanConfig[] }; + const plans = data.plans; + persistPlans(plans); + return plans; + } + + async function startTrial(planName = 'pro'): Promise { + const res = await globalThis.fetch(`${baseUrl}/subscriptions`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ userId, productId, plan: planName, status: 'trialing' }), + }); + if (!res.ok) throw new Error(`startTrial failed: ${res.status}`); + const sub = (await res.json()) as SubscriptionDoc; + persistSub(sub); + return sub; + } + + async function cancelSubscription(): Promise { + const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { + method: 'PUT', + headers: headers(), + body: JSON.stringify({ cancelAtPeriodEnd: true }), + }); + if (!res.ok) throw new Error(`cancelSubscription failed: ${res.status}`); + const sub = (await res.json()) as SubscriptionDoc; + persistSub(sub); + return sub; + } + + async function updateSubscription(updates: Partial): Promise { + const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { + method: 'PUT', + headers: headers(), + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error(`updateSubscription failed: ${res.status}`); + const sub = (await res.json()) as SubscriptionDoc; + persistSub(sub); + return sub; + } + + function isPro(): boolean { + if (!cachedSub) return false; + return ( + cachedSub.plan !== 'free' && + (cachedSub.status === 'active' || cachedSub.status === 'trialing') + ); + } + + function isTrialing(): boolean { + return cachedSub?.status === 'trialing' || false; + } + + function hasFeature(feature: string): boolean { + if (!cachedSub) return false; + const plan = cachedPlans.find(p => p.name === cachedSub!.plan); + if (!plan) return false; + return plan.features.includes(feature); + } + + function daysRemaining(): number | null { + if (!cachedSub) return null; + const end = new Date(cachedSub.currentPeriodEnd).getTime(); + const now = Date.now(); + const diff = end - now; + if (diff <= 0) return 0; + return Math.ceil(diff / (1000 * 60 * 60 * 24)); + } + + function getCachedSubscription(): SubscriptionDoc | null { + return cachedSub; + } + + function getCachedPlans(): PlanConfig[] { + return cachedPlans; + } + + async function refresh(): Promise { + await Promise.all([getMySubscription(), getPlans()]); + } + + return { + getMySubscription, + getPlans, + startTrial, + cancelSubscription, + updateSubscription, + isPro, + isTrialing, + hasFeature, + daysRemaining, + getCachedSubscription, + getCachedPlans, + refresh, + }; +} diff --git a/vendor/bytelyst/subscription-client/src/index.ts b/vendor/bytelyst/subscription-client/src/index.ts new file mode 100644 index 0000000..bd82298 --- /dev/null +++ b/vendor/bytelyst/subscription-client/src/index.ts @@ -0,0 +1,156 @@ +export interface SubscriptionDoc { + id: string; + userId: string; + productId?: string; + plan: string; + status: 'active' | 'trialing' | 'past_due' | 'cancelled' | 'none'; + currentPeriodStart?: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + monthlyPrice?: number; + tokensIncluded?: number; + tokensUsed?: number; + stripeCustomerId?: string; + stripeSubscriptionId?: string; + features?: string[]; + createdAt?: string; + updatedAt?: string; +} + +export interface PlanConfig { + id: string; + name: string; + displayName: string; + price: number; + features: string[]; +} + +export interface SubscriptionClientOptions { + baseUrl: string; + productId: string; + userId: string; + getAccessToken: () => string; +} + +export interface SubscriptionClient { + getMySubscription(): Promise; + getPlans(): Promise; + cancelSubscription(): Promise; + isPro(): boolean; + isTrialing(): boolean; + hasFeature(feature: string): boolean; + daysRemaining(): number | null; +} + +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, ''); +} + +export function createSubscriptionClient(opts: SubscriptionClientOptions): SubscriptionClient { + const base = trimTrailingSlash(opts.baseUrl); + let cached: SubscriptionDoc | null | undefined; + + async function request(path: string, init?: RequestInit): Promise { + const token = opts.getAccessToken(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + headers.set('X-Product-Id', opts.productId); + if (!headers.has('Content-Type') && init?.body !== undefined) { + headers.set('Content-Type', 'application/json'); + } + const res = await fetch(`${base}${path}`, { ...init, headers }); + if (res.status === 404) { + return null as T; + } + if (!res.ok) { + throw new Error(`Subscription API error: ${res.status} ${res.statusText}`); + } + if (res.status === 204) { + return null as T; + } + const text = await res.text(); + if (!text) { + return null as T; + } + return JSON.parse(text) as T; + } + + function subscriptionFromCache(): SubscriptionDoc | null { + if (cached === undefined || cached === null) { + return null; + } + return cached; + } + + return { + async getMySubscription(): Promise { + const data = await request('/billing/subscriptions/me'); + cached = data; + return data; + }, + + async getPlans(): Promise { + const data = await request('/billing/plans'); + if (data == null) { + return []; + } + if (Array.isArray(data)) { + return data; + } + if (data && typeof data === 'object' && 'plans' in data && Array.isArray(data.plans)) { + return data.plans; + } + return []; + }, + + async cancelSubscription(): Promise { + const data = await request('/billing/subscriptions/cancel', { + method: 'POST', + }); + if (data === null) { + throw new Error('Cancel subscription returned no body'); + } + cached = data; + return data; + }, + + isPro(): boolean { + const sub = subscriptionFromCache(); + if (!sub) { + return false; + } + const paid = sub.status === 'active' || sub.status === 'trialing'; + const planLower = sub.plan.toLowerCase(); + const notFree = planLower !== 'free' && planLower !== 'none'; + return paid && notFree; + }, + + isTrialing(): boolean { + return subscriptionFromCache()?.status === 'trialing'; + }, + + hasFeature(feature: string): boolean { + const sub = subscriptionFromCache(); + if (!sub?.features?.length) { + return false; + } + return sub.features.includes(feature); + }, + + daysRemaining(): number | null { + const sub = subscriptionFromCache(); + if (!sub?.currentPeriodEnd) { + return null; + } + const end = new Date(sub.currentPeriodEnd).getTime(); + if (Number.isNaN(end)) { + return null; + } + const ms = end - Date.now(); + if (ms <= 0) { + return 0; + } + return Math.ceil(ms / (1000 * 60 * 60 * 24)); + }, + }; +} diff --git a/vendor/bytelyst/subscription-client/src/types.ts b/vendor/bytelyst/subscription-client/src/types.ts new file mode 100644 index 0000000..6092f49 --- /dev/null +++ b/vendor/bytelyst/subscription-client/src/types.ts @@ -0,0 +1,76 @@ +/** + * Types for @bytelyst/subscription-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface SubscriptionDoc { + id: string; + productId: string; + userId: string; + plan: 'free' | 'pro' | 'enterprise'; + status: 'active' | 'cancelled' | 'past_due' | 'trialing'; + currentPeriodStart: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + monthlyPrice: number; + tokensIncluded: number; + tokensUsed: number; + stripeCustomerId?: string; + stripeSubscriptionId?: string; + createdAt: string; + updatedAt: string; +} + +export interface PlanConfig { + id: string; + productId: string; + name: string; + displayName: string; + price: number; + tokens: number; + words: number; + dictations: number; + features: string[]; + stripePriceId?: string; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SubscriptionClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header on every request. */ + productId: string; + + /** User ID — subscription routes are keyed by userId, not id. */ + userId: string; + + /** Returns a JWT access token, or null if not authenticated. */ + getAccessToken: () => string | null; + + /** Optional persistent storage adapter for cache. */ + storage?: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + }; +} + +export interface SubscriptionClient { + // Server-authoritative checks + getMySubscription(): Promise; + getPlans(): Promise; + startTrial(planName?: string): Promise; + cancelSubscription(): Promise; + updateSubscription(updates: Partial): Promise; + + // Client-side helpers (cached, offline-safe) + isPro(): boolean; + isTrialing(): boolean; + hasFeature(feature: string): boolean; + daysRemaining(): number | null; + getCachedSubscription(): SubscriptionDoc | null; + getCachedPlans(): PlanConfig[]; + refresh(): Promise; +} diff --git a/vendor/bytelyst/subscription-client/tsconfig.json b/vendor/bytelyst/subscription-client/tsconfig.json new file mode 100644 index 0000000..7d61ee3 --- /dev/null +++ b/vendor/bytelyst/subscription-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/survey-client/README.md b/vendor/bytelyst/survey-client/README.md new file mode 100644 index 0000000..d86a83a --- /dev/null +++ b/vendor/bytelyst/survey-client/README.md @@ -0,0 +1,349 @@ +# @bytelyst/survey-client + +TypeScript client for the ByteLyst Survey platform. Provides survey discovery, question flow management, response submission, and offline caching. + +## Installation + +```bash +npm install @bytelyst/survey-client +# or +pnpm add @bytelyst/survey-client +``` + +## Quick Start + +```typescript +import { createSurveyClient } from '@bytelyst/survey-client'; + +const client = createSurveyClient({ + baseURL: 'https://api.bytelyst.io/v1', + productId: 'lysnrai', + getAuthToken: async () => { + return localStorage.getItem('token'); + } +}); + +// Check for active survey +const [survey, error] = await client.getActiveSurvey(); +if (survey) { + console.log('Survey available:', survey.title); +} +``` + +## API Reference + +### `createSurveyClient(config)` + +Creates a new survey client instance. + +**Config:** +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `baseURL` | string | Yes | API base URL | +| `productId` | string | Yes | Product identifier | +| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | +| `enableOfflineCache` | boolean | No | Enable offline response caching (default: true) | + +### Methods + +#### `getActiveSurvey()` +Check if there's an active survey for the current user. + +```typescript +const [survey, error] = await client.getActiveSurvey(); +// Returns: ActiveSurvey | null +``` + +#### `startSurvey(surveyId: string)` +Start a survey session. + +```typescript +const [state, error] = await client.startSurvey('srv_123'); +// Returns: { surveyStateId: string, currentQuestionIndex: number } +``` + +#### `submitAnswer(surveyId: string, questionId: string, answer: SurveyAnswer)` +Submit an answer for a specific question. + +```typescript +const [result, error] = await client.submitAnswer('srv_123', 'q1', { + type: 'rating', + value: { value: 8 } +}); +// Returns: { currentQuestionIndex: number, nextQuestionId: string, isComplete: boolean } +``` + +**Answer Types:** +```typescript +// Single choice +{ type: 'single_choice', value: { value: 'option_1' } } + +// Multiple choice +{ type: 'multiple_choice', value: { values: ['opt_1', 'opt_2'] } } + +// Rating/NPS/Scale +{ type: 'rating', value: { value: 8 } } + +// Text +{ type: 'text', value: { value: 'My feedback here' } } + +// Ranking +{ type: 'ranking', value: { order: ['opt_1', 'opt_2', 'opt_3'] } } +``` + +#### `completeSurvey(surveyId: string)` +Mark survey as complete and claim any incentive. + +```typescript +const [completion, error] = await client.completeSurvey('srv_123'); +// Returns: { success: boolean, incentiveClaimed: boolean, incentiveType?: string, incentiveAmount?: number } +``` + +#### `dismissSurvey(surveyId: string)` +Dismiss survey without completing. + +```typescript +await client.dismissSurvey('srv_123'); +``` + +#### `startPolling(intervalMs: number, callback: (survey) => void)` +Start polling for active surveys. + +```typescript +client.startPolling(60000, (survey) => { + if (survey) { + showSurveyModal(survey); + } +}); +``` + +#### `stopPolling()` +Stop survey polling. + +```typescript +client.stopPolling(); +``` + +#### `flushOfflineQueue()` +Manually flush any cached offline responses. + +```typescript +await client.flushOfflineQueue(); +``` + +## React Integration + +### Hook Usage + +```typescript +import { useSurvey } from './hooks/useSurvey'; + +function SurveyWidget() { + const { + activeSurvey, + currentQuestion, + submitAnswer, + completeSurvey, + progress + } = useSurvey({ + autoStart: true, + pollingInterval: 60000 + }); + + if (!activeSurvey) return null; + + return ( + + ); +} +``` + +### Complete Survey Flow Example + +```typescript +function SurveyFlow() { + const client = useSurveyClient(); + const [survey, setSurvey] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + + useEffect(() => { + loadSurvey(); + }, []); + + async function loadSurvey() { + const [activeSurvey] = await client.getActiveSurvey(); + if (activeSurvey) { + await client.startSurvey(activeSurvey.id); + setSurvey(activeSurvey); + } + } + + async function handleAnswer(questionId: string, answer: SurveyAnswer) { + const [result] = await client.submitAnswer(survey!.id, questionId, answer); + + setAnswers(prev => ({ ...prev, [questionId]: answer })); + setCurrentIndex(result.currentQuestionIndex); + + if (result.isComplete) { + const [completion] = await client.completeSurvey(survey!.id); + if (completion.incentiveClaimed) { + showIncentiveToast(completion.incentiveAmount, completion.incentiveType); + } + } + } + + if (!survey) return null; + + const question = survey.questions[currentIndex]; + const isLast = currentIndex === survey.questions.length - 1; + + return ( + handleAnswer(question.id, answer)} + onSkip={question.required ? undefined : () => handleSkip(question.id)} + /> + ); +} +``` + +## Offline Support + +The client automatically caches responses when offline: + +```typescript +const client = createSurveyClient({ + baseURL: 'https://api.bytelyst.io/v1', + productId: 'lysnrai', + getAuthToken: () => getToken(), + enableOfflineCache: true // Enabled by default +}); + +// Responses are queued when offline +// Flush queue manually or on reconnect +window.addEventListener('online', () => { + client.flushOfflineQueue(); +}); +``` + +## Types + +```typescript +interface ActiveSurvey { + id: string; + title: string; + description?: string; + questions: Question[]; + currentQuestionIndex: number; + incentive?: { + type: 'pro_days' | 'credits'; + amount: number; + }; +} + +interface Question { + id: string; + type: 'single_choice' | 'multiple_choice' | 'rating' | 'scale' | + 'nps' | 'text_short' | 'text_long' | 'ranking' | 'dropdown'; + text: string; + description?: string; + required: boolean; + options?: QuestionOption[]; + minValue?: number; + maxValue?: number; + maxLength?: number; + showIf?: ShowIfCondition; +} + +interface QuestionOption { + id: string; + text: string; + emoji?: string; +} + +interface SurveyAnswer { + type: string; + value: Record; +} + +interface SurveyConfig { + baseURL: string; + productId: string; + getAuthToken: () => Promise; + enableOfflineCache?: boolean; +} +``` + +## Question Type Reference + +| Type | Answer Format | UI Component | +|------|--------------|--------------| +| `single_choice` | `{ value: string }` | Radio group | +| `multiple_choice` | `{ values: string[] }` | Checkboxes | +| `rating` | `{ value: number }` | Star rating (1-5) | +| `scale` | `{ value: number }` | Numeric slider | +| `nps` | `{ value: number }` | 0-10 buttons | +| `text_short` | `{ value: string }` | Single line input | +| `text_long` | `{ value: string }` | Textarea | +| `ranking` | `{ order: string[] }` | Drag-to-sort | +| `dropdown` | `{ value: string }` | Select dropdown | + +## Conditional Logic + +Questions can be shown/hidden based on previous answers: + +```typescript +// Question only shows if q1 answer is NOT 9 or 10 +{ + id: 'q2', + type: 'text_long', + text: 'What could we improve?', + showIf: { + questionId: 'q1', + operator: 'not_equals', + value: ['9', '10'] + } +} +``` + +**Operators:** `equals`, `not_equals`, `greater_than`, `less_than`, `contains` + +## Error Handling + +```typescript +const [data, error] = await client.submitAnswer('srv_123', 'q1', answer); + +if (error) { + switch (error.code) { + case 'SURVEY_NOT_FOUND': + console.error('Survey expired or unavailable'); + break; + case 'ALREADY_COMPLETED': + console.error('User already completed this survey'); + break; + case 'VALIDATION_ERROR': + console.error('Invalid answer format'); + break; + default: + console.error('Survey error:', error.message); + } +} +``` + +## Browser Support + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +## License + +MIT © ByteLyst diff --git a/vendor/bytelyst/survey-client/package.json b/vendor/bytelyst/survey-client/package.json new file mode 100644 index 0000000..656a9b7 --- /dev/null +++ b/vendor/bytelyst/survey-client/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bytelyst/survey-client", + "version": "0.1.5", + "type": "module", + "description": "Browser/React Native-safe survey 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 --pool forks" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/survey-client/src/index.ts b/vendor/bytelyst/survey-client/src/index.ts new file mode 100644 index 0000000..bb2681e --- /dev/null +++ b/vendor/bytelyst/survey-client/src/index.ts @@ -0,0 +1,328 @@ +/** + * Survey Client — Browser/React Native-safe survey client + * @module @bytelyst/survey-client + */ + +// ============================================================================= +// Types +// ============================================================================= + +export type QuestionType = + | 'single_choice' + | 'multiple_choice' + | 'rating' + | 'nps' + | 'text_short' + | 'text_long' + | 'dropdown' + | 'scale' + | 'ranking'; + +export interface QuestionOption { + id: string; + text: string; + emoji?: string; +} + +export interface Question { + id: string; + type: QuestionType; + text: string; + description?: string; + required: boolean; + options?: QuestionOption[]; + minLength?: number; + maxLength?: number; + minValue?: number; + maxValue?: number; +} + +export interface Survey { + id: string; + productId: string; + title: string; + description?: string; + questions: Question[]; + status: 'draft' | 'active' | 'paused' | 'closed'; + startsAt?: string; + endsAt?: string; + displayTrigger: + | { type: 'immediate' } + | { type: 'delay_seconds'; seconds: number } + | { type: 'event'; eventName: string } + | { type: 'page_view'; pagePattern: string }; + incentive?: { type: 'pro_days' | 'credits'; amount: number }; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +export type QuestionAnswer = + | { type: 'single_choice'; optionId: string } + | { type: 'multiple_choice'; optionIds: string[] } + | { type: 'rating'; value: number } + | { type: 'nps'; value: number } + | { type: 'text'; value: string } + | { type: 'ranking'; rankedOptionIds: string[] }; + +export interface SurveyResponse { + id: string; + surveyId: string; + userId: string; + answers: Record; + currentQuestionIndex: number; + startedAt: string; + completedAt?: string; + isComplete: boolean; + incentiveClaimed: boolean; + incentiveClaimedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface SurveyClientConfig { + /** Platform service base URL */ + baseUrl: string; + /** Product ID */ + productId: string; + /** Auth token provider */ + getAuthToken: (() => string) | (() => Promise); + /** Platform identifier */ + platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; + /** App version */ + appVersion: string; + /** OS version */ + osVersion: string; + /** Optional country code */ + countryCode?: string; + /** Optional region code */ + regionCode?: string; + /** User segments (default: ['free']) */ + userSegments?: string[]; + /** Device ID for tracking */ + deviceId?: string; +} + +// ============================================================================= +// Client Factory +// ============================================================================= + +export interface ActiveSurvey { + id: string; + title: string; + description?: string; + questions: Question[]; + incentive?: { type: 'pro_days' | 'credits'; amount: number }; + displayTrigger: Survey['displayTrigger']; +} + +export interface SurveyClient { + /** Get active survey for current user (if any) */ + getActiveSurvey(): Promise<{ survey: ActiveSurvey | null }>; + /** Start a survey session */ + startSurvey(surveyId: string): Promise<{ + responseId: string; + startedAt: string; + currentQuestionIndex: number; + answers: Record; + }>; + /** Submit an answer */ + submitAnswer( + surveyId: string, + questionId: string, + answer: QuestionAnswer + ): Promise<{ + responseId: string; + currentQuestionIndex: number; + answers: Record; + }>; + /** Complete the survey */ + completeSurvey(surveyId: string): Promise<{ + success: boolean; + timeSpentSeconds: number; + incentiveClaimed: boolean; + }>; + /** Dismiss survey (won't show again) */ + dismissSurvey(surveyId: string): Promise; + /** Check for eligible surveys periodically */ + pollSurveys(intervalMs?: number): () => void; + /** Get cached response for a survey (for offline support) */ + getCachedResponse(surveyId: string): SurveyResponse | null; + /** Save response to cache */ + cacheResponse(surveyId: string, response: Partial): void; +} + +export function createSurveyClient(config: SurveyClientConfig): SurveyClient { + const headers = async () => ({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, + 'x-product-id': config.productId, + 'x-platform': config.platform, + 'x-app-version': config.appVersion, + 'x-os-version': config.osVersion, + ...(config.countryCode && { 'x-country-code': config.countryCode }), + ...(config.regionCode && { 'x-region-code': config.regionCode }), + 'x-user-segments': (config.userSegments ?? ['free']).join(','), + ...(config.deviceId && { 'x-device-id': config.deviceId }), + }); + + const request = async (path: string, options?: RequestInit): Promise => { + const res = await fetch(`${config.baseUrl}${path}`, { + ...options, + headers: { + ...(await headers()), + ...(options?.headers || {}), + }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Survey API error: ${res.status} ${err}`); + } + return res.json() as Promise; + }; + + // In-memory cache for offline support + const responseCache = new Map(); + + let pollInterval: ReturnType | null = null; + + return { + async getActiveSurvey() { + return request<{ survey: ActiveSurvey | null }>('/surveys/active'); + }, + + async startSurvey(surveyId: string) { + const result = await request<{ + responseId: string; + startedAt: string; + currentQuestionIndex: number; + answers: Record; + }>(`/surveys/${surveyId}/start`, { method: 'POST' }); + // Cache the initial response + this.cacheResponse(surveyId, { + id: result.responseId, + surveyId, + userId: '', // Will be filled by server + answers: result.answers, + currentQuestionIndex: result.currentQuestionIndex, + startedAt: result.startedAt, + isComplete: false, + incentiveClaimed: false, + createdAt: result.startedAt, + updatedAt: result.startedAt, + }); + return result; + }, + + async submitAnswer(surveyId: string, questionId: string, answer: QuestionAnswer) { + const result = await request<{ + responseId: string; + currentQuestionIndex: number; + answers: Record; + }>(`/surveys/${surveyId}/response`, { + method: 'POST', + body: JSON.stringify({ questionId, answer }), + }); + // Update cache + const cached = responseCache.get(surveyId); + if (cached) { + cached.answers = result.answers; + cached.currentQuestionIndex = result.currentQuestionIndex; + cached.updatedAt = new Date().toISOString(); + } + return result; + }, + + async completeSurvey(surveyId: string) { + const result = await request<{ + success: boolean; + timeSpentSeconds: number; + incentiveClaimed: boolean; + }>(`/surveys/${surveyId}/complete`, { method: 'POST' }); + // Clear cache on completion + responseCache.delete(surveyId); + return result; + }, + + async dismissSurvey(surveyId: string) { + await request(`/surveys/${surveyId}/dismiss`, { method: 'POST' }); + responseCache.delete(surveyId); + }, + + pollSurveys(intervalMs = 60000) { + if (pollInterval) clearInterval(pollInterval); + pollInterval = setInterval(() => { + this.getActiveSurvey().catch(() => {}); + }, intervalMs); + return () => { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + }; + }, + + getCachedResponse(surveyId: string) { + return responseCache.get(surveyId) ?? null; + }, + + cacheResponse(surveyId: string, response: Partial) { + const existing = responseCache.get(surveyId); + responseCache.set(surveyId, { + ...existing, + ...response, + } as SurveyResponse); + }, + }; +} + +// ============================================================================= +// Answer Validation +// ============================================================================= + +export function validateAnswer( + question: Question, + answer: QuestionAnswer +): { valid: boolean; error?: string } { + // Type matching + const expectedType = question.type === 'dropdown' ? 'single_choice' : + question.type === 'scale' ? 'rating' : + question.type === 'text_short' || question.type === 'text_long' ? 'text' : + question.type; + + if (answer.type !== expectedType) { + return { valid: false, error: `Expected ${expectedType}, got ${answer.type}` }; + } + + // Value range validation + if (answer.type === 'rating' || answer.type === 'nps') { + if (question.minValue !== undefined && answer.value < question.minValue) { + return { valid: false, error: `Value below minimum ${question.minValue}` }; + } + if (question.maxValue !== undefined && answer.value > question.maxValue) { + return { valid: false, error: `Value above maximum ${question.maxValue}` }; + } + } + + // Text length validation + if (answer.type === 'text') { + if (question.minLength !== undefined && answer.value.length < question.minLength) { + return { valid: false, error: `Text too short (min ${question.minLength})` }; + } + if (question.maxLength !== undefined && answer.value.length > question.maxLength) { + return { valid: false, error: `Text too long (max ${question.maxLength})` }; + } + } + + return { valid: true }; +} + +// ============================================================================= +// React Hook (optional) +// ============================================================================= + +export function createUseSurvey(client: SurveyClient) { + return function useSurvey() { + return { client }; + }; +} diff --git a/vendor/bytelyst/survey-client/tsconfig.json b/vendor/bytelyst/survey-client/tsconfig.json new file mode 100644 index 0000000..3686f56 --- /dev/null +++ b/vendor/bytelyst/survey-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vendor/bytelyst/swift-diagnostics/Package.swift b/vendor/bytelyst/swift-diagnostics/Package.swift new file mode 100644 index 0000000..1230ec5 --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ByteLystDiagnostics", + platforms: [ + .iOS(.v15), + .macOS(.v13), + .watchOS(.v8), + .tvOS(.v15) + ], + products: [ + .library( + name: "ByteLystDiagnostics", + targets: ["ByteLystDiagnostics"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "ByteLystDiagnostics", + path: "Sources/ByteLystDiagnostics", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency") + ] + ), + .testTarget( + name: "ByteLystDiagnosticsTests", + dependencies: ["ByteLystDiagnostics"], + path: "Tests/ByteLystDiagnosticsTests" + ), + ] +) diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift new file mode 100644 index 0000000..80a9033 --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift @@ -0,0 +1,73 @@ +/** + * ByteLystDiagnostics + * + * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. + * Provides polling, logging, tracing, network capture, and breadcrumbs for iOS/macOS. + * + * Example usage: + * ```swift + * import ByteLystDiagnostics + * + * // Configure + * let config = DiagnosticsConfiguration( + * productId: "myapp", + * anonymousInstallId: "install_123", + * platform: "ios", + * channel: "ios_app", + * osFamily: "ios", + * appVersion: "1.0.0", + * buildNumber: "100", + * releaseChannel: "stable", + * serverUrl: "https://api.bytelyst.com" + * ) + * + * await DiagnosticsClient.shared.configure(config) + * await DiagnosticsClient.shared.start() + * + * // Auto-instrumented trace + * let result = try await DiagnosticsClient.shared.trace(name: "fetchUser") { + * try await fetchUser() + * } + * + * // Manual breadcrumb + * await DiagnosticsClient.shared.breadcrumb( + * category: "user", + * message: "Tapped submit button", + * data: ["buttonId": AnyCodable("submit")] + * ) + * + * // Manual log + * await DiagnosticsClient.shared.log( + * level: .info, + * message: "User signed in", + * module: "Auth", + * context: ["userId": AnyCodable(userId)] + * ) + * ``` + */ + +// MARK: - Core +@_exported import Foundation + +// Types +public typealias ByteLystDiagnosticsTypes = DiagnosticsSession +public typealias ByteLystDiagnosticsLogLevel = DiagnosticsLogLevel +public typealias ByteLystDiagnosticsSessionStatus = DiagnosticsSessionStatus +public typealias ByteLystDiagnosticsCollectionLevel = DiagnosticsCollectionLevel +public typealias ByteLystDiagnosticsTraceSpan = DiagnosticsTraceSpan +public typealias ByteLystDiagnosticsLogEntry = DiagnosticsLogEntry +public typealias ByteLystDiagnosticsBreadcrumb = DiagnosticsBreadcrumb +public typealias ByteLystDiagnosticsNetworkRequest = DiagnosticsNetworkRequest +public typealias ByteLystDiagnosticsDeviceState = DiagnosticsDeviceState +public typealias ByteLystDiagnosticsIngestBatch = DiagnosticsIngestBatch +public typealias ByteLystDiagnosticsClientState = DiagnosticsClientState +public typealias ByteLystDiagnosticsConfiguration = DiagnosticsConfiguration +public typealias ByteLystDiagnosticsLogger = DiagnosticsLogger +public typealias ByteLystDiagnosticsNoOpLogger = NoOpDiagnosticsLogger +public typealias ByteLystDiagnosticsOSLogger = OSDiagnosticsLogger + +// Errors +public typealias ByteLystDiagnosticsError = DiagnosticsError + +// Version +public let ByteLystDiagnosticsVersion = "0.1.0" diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift new file mode 100644 index 0000000..6c0f17f --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Ring buffer for breadcrumbs with fixed max size +public actor BreadcrumbTrail { + private var breadcrumbs: [DiagnosticsBreadcrumb] = [] + private let maxSize: Int + + public init(maxSize: Int = 100) { + self.maxSize = maxSize + } + + /// Add a breadcrumb to the trail + public func add(category: String, message: String, data: [String: AnyCodable]? = nil) { + let breadcrumb = DiagnosticsBreadcrumb( + timestamp: ISO8601DateFormatter().string(from: Date()), + category: category, + message: message, + data: data + ) + + breadcrumbs.append(breadcrumb) + + // Evict oldest if over limit + if breadcrumbs.count > maxSize { + breadcrumbs.removeFirst() + } + } + + /// Get all breadcrumbs (oldest first) + public func getAll() -> [DiagnosticsBreadcrumb] { + breadcrumbs + } + + /// Get last N breadcrumbs + public func getLast(_ n: Int) -> [DiagnosticsBreadcrumb] { + Array(breadcrumbs.suffix(n)) + } + + /// Get most recent breadcrumb + public func getMostRecent() -> DiagnosticsBreadcrumb? { + breadcrumbs.last + } + + /// Clear all breadcrumbs + public func clear() { + breadcrumbs.removeAll() + } + + /// Get current size + public func size() -> Int { + breadcrumbs.count + } +} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift new file mode 100644 index 0000000..2f7ef6a --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Client configuration +public struct DiagnosticsConfiguration: Sendable { + public let productId: String + public let userId: String? + public let anonymousInstallId: String + public let platform: String + public let channel: String + public let osFamily: String + public let appVersion: String + public let buildNumber: String + public let releaseChannel: String + public let serverUrl: String + public let pollIntervalMs: Int + public let maxBreadcrumbs: Int + public let captureConsole: Bool + public let captureErrors: Bool + public let captureNetwork: Bool + public let getAuthToken: (@Sendable () async throws -> String)? + + public init( + productId: String, + userId: String? = nil, + anonymousInstallId: String, + platform: String, + channel: String, + osFamily: String, + appVersion: String, + buildNumber: String, + releaseChannel: String, + serverUrl: String, + pollIntervalMs: Int = 5000, + maxBreadcrumbs: Int = 100, + captureConsole: Bool = true, + captureErrors: Bool = true, + captureNetwork: Bool = true, + getAuthToken: (@Sendable () async throws -> String)? = nil + ) { + self.productId = productId + self.userId = userId + self.anonymousInstallId = anonymousInstallId + self.platform = platform + self.channel = channel + self.osFamily = osFamily + self.appVersion = appVersion + self.buildNumber = buildNumber + self.releaseChannel = releaseChannel + self.serverUrl = serverUrl + self.pollIntervalMs = pollIntervalMs + self.maxBreadcrumbs = maxBreadcrumbs + self.captureConsole = captureConsole + self.captureErrors = captureErrors + self.captureNetwork = captureNetwork + self.getAuthToken = getAuthToken + } +} + +/// Logger protocol +public protocol DiagnosticsLogger: Sendable { + func debug(_ message: String, metadata: [String: any Sendable]?) + func info(_ message: String, metadata: [String: any Sendable]?) + func warn(_ message: String, metadata: [String: any Sendable]?) + func error(_ message: String, metadata: [String: any Sendable]?) +} + +/// Default no-op logger +public struct NoOpDiagnosticsLogger: DiagnosticsLogger { + public init() {} + public func debug(_ message: String, metadata: [String: any Sendable]?) {} + public func info(_ message: String, metadata: [String: any Sendable]?) {} + public func warn(_ message: String, metadata: [String: any Sendable]?) {} + public func error(_ message: String, metadata: [String: any Sendable]?) {} +} + +/// OSLog-based logger +public struct OSDiagnosticsLogger: DiagnosticsLogger { + private let subsystem: String + private let category: String + + public init(subsystem: String = "com.bytelyst.diagnostics", category: String = "DiagnosticsClient") { + self.subsystem = subsystem + self.category = category + } + + public func debug(_ message: String, metadata: [String: any Sendable]?) { + #if DEBUG + print("[DEBUG] \(message)") + #endif + } + + public func info(_ message: String, metadata: [String: any Sendable]?) { + print("[INFO] \(message)") + } + + public func warn(_ message: String, metadata: [String: any Sendable]?) { + print("[WARN] \(message)") + } + + public func error(_ message: String, metadata: [String: any Sendable]?) { + print("[ERROR] \(message)") + } +} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift new file mode 100644 index 0000000..fd16053 --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift @@ -0,0 +1,397 @@ +import Foundation + +/// Thread-safe diagnostics client using Swift actors +public actor DiagnosticsClient { + public static let shared = DiagnosticsClient() + + private var configuration: DiagnosticsConfiguration? + private var logger: DiagnosticsLogger = NoOpDiagnosticsLogger() + private var state: DiagnosticsClientState = .idle + private var breadcrumbs = BreadcrumbTrail(maxSize: 100) + private var logBuffer: [DiagnosticsLogEntry] = [] + private var traceBuffer: [DiagnosticsTraceSpan] = [] + private var networkBuffer: [DiagnosticsNetworkRequest] = [] + private var pollTimer: Timer? + private var flushTask: Task? + private var lastEtag: String? + private var networkInterceptor: NetworkInterceptor? + + // MARK: - Singleton + + private init() {} + + // MARK: - Configuration + + public func configure(_ config: DiagnosticsConfiguration, logger: DiagnosticsLogger? = nil) { + self.configuration = config + self.logger = logger ?? NoOpDiagnosticsLogger() + self.breadcrumbs = BreadcrumbTrail(maxSize: config.maxBreadcrumbs) + } + + // MARK: - Lifecycle + + public func start() async { + guard case .idle = state else { + await logger.warn("[diagnostics] Already started", metadata: nil) + return + } + + guard let config = configuration else { + await logger.error("[diagnostics] Not configured", metadata: nil) + state = .error(DiagnosticsError.notConfigured) + return + } + + await logger.info("[diagnostics] Starting diagnostics client", metadata: nil) + state = .polling(session: nil) + + // Initial poll + await pollForSession() + + // Setup network capture if enabled + if config.captureNetwork { + setupNetworkCapture() + } + + // Start flush timer (every 30 seconds) + startFlushTimer() + + await breadcrumbs.add(category: "diagnostics", message: "Client started") + } + + public func stop() async { + await logger.info("[diagnostics] Stopping diagnostics client", metadata: nil) + + pollTimer?.invalidate() + pollTimer = nil + + flushTask?.cancel() + flushTask = nil + + networkInterceptor?.stop() + networkInterceptor = nil + + // Final flush + await flush() + + state = .idle + await breadcrumbs.add(category: "diagnostics", message: "Client stopped") + } + + // MARK: - State + + public func isSessionActive() -> Bool { + if case .active = state { + return true + } + return false + } + + public func getCurrentSession() -> DiagnosticsSession? { + switch state { + case .active(let session), .polling(let session): + return session + default: + return nil + } + } + + public func getState() -> DiagnosticsClientState { + state + } + + // MARK: - Logging + + public func log( + level: DiagnosticsLogLevel, + message: String, + module: String = "unknown", + file: String? = nil, + line: Int? = nil, + function: String? = nil, + context: [String: AnyCodable] = [:], + correlationId: String? = nil + ) async { + let entry = DiagnosticsLogEntry( + level: level, + message: message, + timestamp: ISO8601DateFormatter().string(from: Date()), + module: module, + file: file, + line: line, + function: function, + context: context, + correlationId: correlationId + ) + + logBuffer.append(entry) + await breadcrumbs.add( + category: "log", + message: "[\(level.rawValue.uppercased())] \(String(message.prefix(100)))", + data: ["level": AnyCodable(level.rawValue)] + ) + + // Auto-flush on fatal + if level == .fatal { + Task { await flush() } + } + } + + // MARK: - Tracing + + public func trace( + name: String, + operation: () async throws -> T + ) async rethrows -> T { + let spanId = generateId() + let startTime = ISO8601DateFormatter().string(from: Date()) + + await breadcrumbs.add( + category: "trace", + message: "Starting: \(name)", + data: ["spanId": AnyCodable(spanId)] + ) + + do { + let result = try await operation() + let endTime = ISO8601DateFormatter().string(from: Date()) + let start = ISO8601DateFormatter().date(from: startTime) ?? Date() + let end = ISO8601DateFormatter().date(from: endTime) ?? Date() + let durationMs = end.timeIntervalSince(start) * 1000 + + let span = DiagnosticsTraceSpan( + spanId: spanId, + name: name, + startTime: startTime, + endTime: endTime, + durationMs: durationMs, + attributes: [:], + status: .ok + ) + traceBuffer.append(span) + + await breadcrumbs.add( + category: "trace", + message: "Completed: \(name)", + data: [ + "spanId": AnyCodable(spanId), + "durationMs": AnyCodable(durationMs) + ] + ) + + return result + } catch { + let endTime = ISO8601DateFormatter().string(from: Date()) + let start = ISO8601DateFormatter().date(from: startTime) ?? Date() + let end = ISO8601DateFormatter().date(from: endTime) ?? Date() + let durationMs = end.timeIntervalSince(start) * 1000 + + let span = DiagnosticsTraceSpan( + spanId: spanId, + name: name, + startTime: startTime, + endTime: endTime, + durationMs: durationMs, + attributes: [:], + status: .error, + statusMessage: error.localizedDescription + ) + traceBuffer.append(span) + + await breadcrumbs.add( + category: "trace", + message: "Failed: \(name)", + data: [ + "spanId": AnyCodable(spanId), + "error": AnyCodable(error.localizedDescription) + ] + ) + + throw error + } + } + + // MARK: - Breadcrumbs + + public func breadcrumb( + category: String, + message: String, + data: [String: AnyCodable]? = nil + ) async { + await breadcrumbs.add(category: category, message: message, data: data) + } + + public func getBreadcrumbs() -> [DiagnosticsBreadcrumb] { + breadcrumbs.getAll() + } + + // MARK: - Private + + private func pollForSession() async { + guard let config = configuration else { return } + + var request = URLRequest( + url: URL(string: "\(config.serverUrl)/api/diagnostics/config?productId=\(config.productId)&installId=\(config.anonymousInstallId)")! + ) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if let etag = lastEtag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + if let getToken = config.getAuthToken { + do { + let token = try await getToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } catch { + await logger.error("[diagnostics] Failed to get auth token", metadata: ["error": error.localizedDescription]) + } + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw DiagnosticsError.invalidResponse + } + + if httpResponse.statusCode == 304 { + // No change + return + } + + guard httpResponse.statusCode == 200 else { + throw DiagnosticsError.httpError(statusCode: httpResponse.statusCode) + } + + // Store ETag + if let etag = httpResponse.allHeaderFields["Etag"] as? String { + lastEtag = etag + } + + let decoder = JSONDecoder() + let session = try? decoder.decode(DiagnosticsSession.self, from: data) + + if let session = session, session.status == .active { + if case .active = state { + // Already active, just update session + } else { + await logger.info("[diagnostics] Session activated", metadata: ["sessionId": session.id]) + await breadcrumbs.add(category: "diagnostics", message: "Session activated", data: ["sessionId": AnyCodable(session.id)]) + } + state = .active(session: session) + } else { + if case .active = state { + await logger.info("[diagnostics] Session ended", metadata: nil) + await breadcrumbs.add(category: "diagnostics", message: "Session ended") + } + state = .polling(session: nil) + } + } catch { + await logger.error("[diagnostics] Failed to poll for session", metadata: ["error": error.localizedDescription]) + state = .error(error) + } + } + + private func flush() async { + guard let session = getCurrentSession() else { + logBuffer.removeAll() + traceBuffer.removeAll() + networkBuffer.removeAll() + return + } + + let batch = DiagnosticsIngestBatch( + sessionId: session.id, + traces: traceBuffer.isEmpty ? nil : traceBuffer, + logs: logBuffer.isEmpty ? nil : logBuffer, + breadcrumbs: breadcrumbs.getAll().isEmpty ? nil : breadcrumbs.getAll(), + network: networkBuffer.isEmpty ? nil : networkBuffer + ) + + // Clear buffers + logBuffer.removeAll() + traceBuffer.removeAll() + networkBuffer.removeAll() + breadcrumbs.clear() + + guard let config = configuration else { return } + + var request = URLRequest( + url: URL(string: "\(config.serverUrl)/api/diagnostics/ingest")! + ) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let getToken = config.getAuthToken { + do { + let token = try await getToken() + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } catch { + await logger.error("[diagnostics] Failed to get auth token for flush", metadata: ["error": error.localizedDescription]) + } + } + + do { + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(batch) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw DiagnosticsError.flushFailed + } + + await logger.debug("[diagnostics] Flushed batch", metadata: [ + "logs": batch.logs?.count ?? 0, + "traces": batch.traces?.count ?? 0, + "network": batch.network?.count ?? 0 + ]) + } catch { + await logger.error("[diagnostics] Failed to flush batch", metadata: ["error": error.localizedDescription]) + + // Put items back in buffers for retry + if let logs = batch.logs { logBuffer.append(contentsOf: logs) } + if let traces = batch.traces { traceBuffer.append(contentsOf: traces) } + if let network = batch.network { networkBuffer.append(contentsOf: network) } + } + } + + private func startFlushTimer() { + flushTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds + await flush() + } + } + } + + private func setupNetworkCapture() { + networkInterceptor = NetworkInterceptor { [weak self] request in + Task { [weak self] in + await self?.networkBuffer.append(request) + } + } + networkInterceptor?.start() + } + + private func generateId() -> String { + "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" + } +} + +/// Client state enum +public enum DiagnosticsClientState: Sendable { + case idle + case polling(session: DiagnosticsSession?) + case active(session: DiagnosticsSession) + case error(Error) +} + +/// Diagnostics errors +public enum DiagnosticsError: Error, Sendable { + case notConfigured + case invalidResponse + case httpError(statusCode: Int) + case flushFailed +} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift new file mode 100644 index 0000000..cf2038d --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift @@ -0,0 +1,335 @@ +import Foundation + +/// Log severity levels (matches syslog/OpenTelemetry) +public enum DiagnosticsLogLevel: String, Codable, Sendable { + case debug + case info + case warn + case error + case fatal +} + +/// Session status from the server +public enum DiagnosticsSessionStatus: String, Codable, Sendable { + case pending + case active + case paused + case completed + case cancelled +} + +/// Collection level determines verbosity of captured data +public enum DiagnosticsCollectionLevel: String, Codable, Sendable { + case standard + case debug + case trace +} + +/// Diagnostic session configuration from server +public struct DiagnosticsSession: Codable, Sendable { + public let id: String + public let productId: String + public let status: DiagnosticsSessionStatus + public let collectionLevel: DiagnosticsCollectionLevel + public let captureLogs: Bool + public let captureNetwork: Bool + public let captureScreenshots: Bool + public let screenshotOnError: Bool + public let maxDurationMinutes: Int + public let createdAt: String + public let expiresAt: String + + public init( + id: String, + productId: String, + status: DiagnosticsSessionStatus, + collectionLevel: DiagnosticsCollectionLevel, + captureLogs: Bool, + captureNetwork: Bool, + captureScreenshots: Bool, + screenshotOnError: Bool, + maxDurationMinutes: Int, + createdAt: String, + expiresAt: String + ) { + self.id = id + self.productId = productId + self.status = status + self.collectionLevel = collectionLevel + self.captureLogs = captureLogs + self.captureNetwork = captureNetwork + self.captureScreenshots = captureScreenshots + self.screenshotOnError = screenshotOnError + self.maxDurationMinutes = maxDurationMinutes + self.createdAt = createdAt + self.expiresAt = expiresAt + } +} + +/// Span kind for OpenTelemetry compatibility +public enum DiagnosticsSpanKind: String, Codable, Sendable { + case internal + case server + case client + case producer + case consumer +} + +/// Span status +public enum DiagnosticsSpanStatus: String, Codable, Sendable { + case ok + case error + case unset +} + +/// OpenTelemetry-compatible trace span +public struct DiagnosticsTraceSpan: Codable, Sendable { + public let spanId: String + public let parentId: String? + public let name: String + public let kind: DiagnosticsSpanKind? + public let startTime: String + public let endTime: String? + public let durationMs: Double? + public let attributes: [String: AnyCodable] + public let status: DiagnosticsSpanStatus + public let statusMessage: String? + + public init( + spanId: String, + parentId: String? = nil, + name: String, + kind: DiagnosticsSpanKind? = nil, + startTime: String, + endTime: String? = nil, + durationMs: Double? = nil, + attributes: [String: AnyCodable] = [:], + status: DiagnosticsSpanStatus, + statusMessage: String? = nil + ) { + self.spanId = spanId + self.parentId = parentId + self.name = name + self.kind = kind + self.startTime = startTime + self.endTime = endTime + self.durationMs = durationMs + self.attributes = attributes + self.status = status + self.statusMessage = statusMessage + } +} + +/// Structured log entry +public struct DiagnosticsLogEntry: Codable, Sendable { + public let level: DiagnosticsLogLevel + public let message: String + public let timestamp: String + public let module: String + public let file: String? + public let line: Int? + public let function: String? + public let context: [String: AnyCodable] + public let correlationId: String? + + public init( + level: DiagnosticsLogLevel, + message: String, + timestamp: String, + module: String, + file: String? = nil, + line: Int? = nil, + function: String? = nil, + context: [String: AnyCodable] = [:], + correlationId: String? = nil + ) { + self.level = level + self.message = message + self.timestamp = timestamp + self.module = module + self.file = file + self.line = line + self.function = function + self.context = context + self.correlationId = correlationId + } +} + +/// Breadcrumb for timeline navigation +public struct DiagnosticsBreadcrumb: Codable, Sendable { + public let timestamp: String + public let category: String + public let message: String + public let data: [String: AnyCodable]? + + public init( + timestamp: String, + category: String, + message: String, + data: [String: AnyCodable]? = nil + ) { + self.timestamp = timestamp + self.category = category + self.message = message + self.data = data + } +} + +/// Network request/response capture +public struct DiagnosticsNetworkRequest: Codable, Sendable { + public let id: String + public let url: String + public let method: String + public let requestHeaders: [String: String] + public let requestBody: String? + public let status: Int? + public let responseHeaders: [String: String]? + public let responseBody: String? + public let startTime: String + public let endTime: String? + public let durationMs: Double? + public let error: String? + + public init( + id: String, + url: String, + method: String, + requestHeaders: [String: String] = [:], + requestBody: String? = nil, + status: Int? = nil, + responseHeaders: [String: String]? = nil, + responseBody: String? = nil, + startTime: String, + endTime: String? = nil, + durationMs: Double? = nil, + error: String? = nil + ) { + self.id = id + self.url = url + self.method = method + self.requestHeaders = requestHeaders + self.requestBody = requestBody + self.status = status + self.responseHeaders = responseHeaders + self.responseBody = responseBody + self.startTime = startTime + self.endTime = endTime + self.durationMs = durationMs + self.error = error + } +} + +/// Device state snapshot +public struct DiagnosticsDeviceState: Codable, Sendable { + public let memoryMB: Int? + public let batteryLevel: Double? + public let isCharging: Bool? + public let storageMB: Int? + public let networkType: String? + public let isOnline: Bool + public let thermalState: DiagnosticsThermalState? + + public init( + memoryMB: Int? = nil, + batteryLevel: Double? = nil, + isCharging: Bool? = nil, + storageMB: Int? = nil, + networkType: String? = nil, + isOnline: Bool, + thermalState: DiagnosticsThermalState? = nil + ) { + self.memoryMB = memoryMB + self.batteryLevel = batteryLevel + self.isCharging = isCharging + self.storageMB = storageMB + self.networkType = networkType + self.isOnline = isOnline + self.thermalState = thermalState + } +} + +/// Thermal state +public enum DiagnosticsThermalState: String, Codable, Sendable { + case nominal + case fair + case serious + case critical +} + +/// Client state +public enum DiagnosticsClientState: Sendable { + case idle + case polling(session: DiagnosticsSession?) + case active(session: DiagnosticsSession) + case error(Error) +} + +/// Ingest batch for sending to server +public struct DiagnosticsIngestBatch: Codable, Sendable { + public let sessionId: String + public let traces: [DiagnosticsTraceSpan]? + public let logs: [DiagnosticsLogEntry]? + public let breadcrumbs: [DiagnosticsBreadcrumb]? + public let network: [DiagnosticsNetworkRequest]? + + public init( + sessionId: String, + traces: [DiagnosticsTraceSpan]? = nil, + logs: [DiagnosticsLogEntry]? = nil, + breadcrumbs: [DiagnosticsBreadcrumb]? = nil, + network: [DiagnosticsNetworkRequest]? = nil + ) { + self.sessionId = sessionId + self.traces = traces + self.logs = logs + self.breadcrumbs = breadcrumbs + self.network = network + } +} + +/// Type-erased Codable wrapper for dictionary values +public struct AnyCodable: Codable, Sendable { + private let value: any Codable + + public init(_ value: any Codable) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue + } else if let dictValue = try? container.decode([String: AnyCodable].self) { + value = dictValue + } else { + value = "" + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let boolValue = value as? Bool { + try container.encode(boolValue) + } else if let intValue = value as? Int { + try container.encode(intValue) + } else if let doubleValue = value as? Double { + try container.encode(doubleValue) + } else if let stringValue = value as? String { + try container.encode(stringValue) + } else if let arrayValue = value as? [AnyCodable] { + try container.encode(arrayValue) + } else if let dictValue = value as? [String: AnyCodable] { + try container.encode(dictValue) + } else { + try container.encode("") + } + } +} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift new file mode 100644 index 0000000..dcd60d6 --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift @@ -0,0 +1,191 @@ +import Foundation +import UIKit +#if os(iOS) +import SystemConfiguration +#endif + +/// Device state collector +public struct DeviceStateCollector { + + /// Collect current device state + public static func collect() -> DiagnosticsDeviceState { + #if os(iOS) + return DiagnosticsDeviceState( + memoryMB: getMemoryUsage(), + batteryLevel: getBatteryLevel(), + isCharging: getIsCharging(), + storageMB: getStorageUsage(), + networkType: getNetworkType(), + isOnline: getIsOnline(), + thermalState: getThermalState() + ) + #elseif os(macOS) + return DiagnosticsDeviceState( + memoryMB: getMemoryUsage(), + batteryLevel: nil, + isCharging: nil, + storageMB: nil, + networkType: nil, + isOnline: getIsOnline(), + thermalState: nil + ) + #else + return DiagnosticsDeviceState( + memoryMB: nil, + batteryLevel: nil, + isCharging: nil, + storageMB: nil, + networkType: nil, + isOnline: true, + thermalState: nil + ) + #endif + } + + #if os(iOS) + private static func getMemoryUsage() -> Int? { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size)/4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + guard kerr == KERN_SUCCESS else { return nil } + return Int(info.resident_size / 1024 / 1024) + } + + private static func getBatteryLevel() -> Double? { + UIDevice.current.isBatteryMonitoringEnabled = true + return Double(UIDevice.current.batteryLevel) + } + + private static func getIsCharging() -> Bool? { + UIDevice.current.isBatteryMonitoringEnabled = true + return UIDevice.current.batteryState == .charging + } + + private static func getStorageUsage() -> Int? { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) + if let totalSize = attributes[.systemSize] as? NSNumber, + let freeSize = attributes[.systemFreeSize] as? NSNumber { + let usedSize = totalSize.int64Value - freeSize.int64Value + return Int(usedSize / 1024 / 1024) + } + } catch { + return nil + } + return nil + } + + private static func getNetworkType() -> String? { + // Simplified - would need more complex reachability check for actual implementation + if getIsOnline() { + return "wifi" // Default assumption + } + return "offline" + } + + private static func getThermalState() -> DiagnosticsThermalState? { + switch ProcessInfo.processInfo.thermalState { + case .nominal: + return .nominal + case .fair: + return .fair + case .serious: + return .serious + case .critical: + return .critical + @unknown default: + return nil + } + } + #endif + + private static func getIsOnline() -> Bool { + #if os(iOS) || os(macOS) + var zeroAddress = sockaddr_in() + zeroAddress.sin_len = UInt8(MemoryLayout.size) + zeroAddress.sin_family = sa_family_t(AF_INET) + + guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + SCNetworkReachabilityCreateWithAddress(nil, $0) + } + }) else { + return false + } + + var flags: SCNetworkReachabilityFlags = [] + if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { + return false + } + + let isReachable = flags.contains(.reachable) + let needsConnection = flags.contains(.connectionRequired) + + return isReachable && !needsConnection + #else + return true + #endif + } +} + +// MARK: - Connectivity Monitoring + +#if os(iOS) || os(macOS) +import SystemConfiguration + +/// Monitor network connectivity changes +public final class ConnectivityMonitor { + private var reachability: SCNetworkReachability? + private var callback: ((Bool) -> Void)? + + public init() { + var zeroAddress = sockaddr_in() + zeroAddress.sin_len = UInt8(MemoryLayout.size) + zeroAddress.sin_family = sa_family_t(AF_INET) + + reachability = withUnsafePointer(to: &zeroAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + SCNetworkReachabilityCreateWithAddress(nil, $0) + } + } + } + + public func startMonitoring(callback: @escaping (Bool) -> Void) { + self.callback = callback + + guard let reachability = reachability else { return } + + let context = SCNetworkReachabilityContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + SCNetworkReachabilitySetCallback(reachability, { (_, flags, info) in + guard let info = info else { return } + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + + let isReachable = flags.contains(.reachable) + let needsConnection = flags.contains(.connectionRequired) + let isConnected = isReachable && !needsConnection + + monitor.callback?(isConnected) + }, &context) + + SCNetworkReachabilityScheduleWithRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + } + + public func stopMonitoring() { + guard let reachability = reachability else { return } + SCNetworkReachabilityUnscheduleFromRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + } +} +#endif diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift new file mode 100644 index 0000000..b7fd71b --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift @@ -0,0 +1,160 @@ +import Foundation + +/// Network interceptor using URLProtocol for automatic capture +public final class NetworkInterceptor: URLProtocol { + public static var onRequest: ((DiagnosticsNetworkRequest) -> Void)? + private static let shared = NetworkInterceptor() + private var requestId: String? + private var startTime: Date? + private var request: URLRequest? + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + private var dataTask: URLSessionDataTask? + + // MARK: - URLProtocol Overrides + + public override class func canInit(with request: URLRequest) -> Bool { + // Don't intercept our own requests + if request.value(forHTTPHeaderField: "X-Diagnostics-Intercepted") != nil { + return false + } + return true + } + + public override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + public override func startLoading() { + requestId = "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" + startTime = Date() + + var newRequest = request + newRequest?.setValue("true", forHTTPHeaderField: "X-Diagnostics-Intercepted") + + self.request = newRequest + + dataTask = session.dataTask(with: newRequest!) + dataTask?.resume() + } + + public override func stopLoading() { + dataTask?.cancel() + } + + // MARK: - Public API + + public static func start() { + URLProtocol.registerClass(NetworkInterceptor.self) + } + + public static func stop() { + URLProtocol.unregisterClass(NetworkInterceptor.self) + } + + public func start(with handler: @escaping (DiagnosticsNetworkRequest) -> Void) { + NetworkInterceptor.onRequest = handler + NetworkInterceptor.start() + } + + public func stopInterceptor() { + NetworkInterceptor.stop() + NetworkInterceptor.onRequest = nil + } +} + +// MARK: - URLSessionDataDelegate + +extension NetworkInterceptor: URLSessionDataDelegate { + public func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + client?.urlProtocol(self, didLoad: data) + } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let request = request, + let requestId = requestId, + let startTime = startTime else { + return + } + + let endTime = Date() + let durationMs = endTime.timeIntervalSince(startTime) * 1000 + + var networkRequest = DiagnosticsNetworkRequest( + id: requestId, + url: request.url?.absoluteString ?? "", + method: request.httpMethod ?? "GET", + requestHeaders: request.allHTTPHeaderFields?.mapValues { value in + NetworkInterceptor.sanitizeHeader(value, key: "") + } ?? [:], + requestBody: request.httpBody.flatMap { String(data: $0, encoding: .utf8) }, + startTime: ISO8601DateFormatter().string(from: startTime), + endTime: ISO8601DateFormatter().string(from: endTime), + durationMs: durationMs + ) + + if let error = error { + networkRequest = DiagnosticsNetworkRequest( + id: requestId, + url: networkRequest.url, + method: networkRequest.method, + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + status: nil, + responseHeaders: nil, + responseBody: nil, + startTime: networkRequest.startTime, + endTime: networkRequest.endTime, + durationMs: networkRequest.durationMs, + error: error.localizedDescription + ) + } else if let response = task.response as? HTTPURLResponse { + networkRequest = DiagnosticsNetworkRequest( + id: requestId, + url: networkRequest.url, + method: networkRequest.method, + requestHeaders: networkRequest.requestHeaders, + requestBody: networkRequest.requestBody, + status: response.statusCode, + responseHeaders: response.allHeaderFields as? [String: String], + responseBody: nil, // Don't capture response body (too large) + startTime: networkRequest.startTime, + endTime: networkRequest.endTime, + durationMs: networkRequest.durationMs, + error: nil + ) + } + + NetworkInterceptor.onRequest?(networkRequest) + + if let error = error { + client?.urlProtocol(self, didFailWithError: error) + } else { + client?.urlProtocolDidFinishLoading(self) + } + } + + private static func sanitizeHeader(_ value: String, key: String) -> String { + let sensitivePatterns = ["authorization", "cookie", "token", "api-key"] + let lowerKey = key.lowercased() + + for pattern in sensitivePatterns { + if lowerKey.contains(pattern) { + return "[REDACTED]" + } + } + return value + } +} diff --git a/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift b/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift new file mode 100644 index 0000000..63f1263 --- /dev/null +++ b/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift @@ -0,0 +1,296 @@ +import XCTest +@testable import ByteLystDiagnostics + +final class DiagnosticsClientTests: XCTestCase { + + override func setUp() { + super.setUp() + // Reset to initial state + Task { + // Client is actor, need to await + } + } + + // MARK: - Singleton Tests + + func testSharedInstance() { + let client1 = DiagnosticsClient.shared + let client2 = DiagnosticsClient.shared + XCTAssertTrue(client1 === client2, "Shared instance should be singleton") + } + + // MARK: - Configuration Tests + + func testConfiguration() async { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + let state = await DiagnosticsClient.shared.getState() + XCTAssertEqual(state, .idle) + } + + // MARK: - Session State Tests + + func testInitialSessionState() async { + let isActive = await DiagnosticsClient.shared.isSessionActive() + XCTAssertFalse(isActive) + + let session = await DiagnosticsClient.shared.getCurrentSession() + XCTAssertNil(session) + } + + // MARK: - Breadcrumb Tests + + func testBreadcrumbAdding() async { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + await DiagnosticsClient.shared.breadcrumb( + category: "navigation", + message: "Page loaded", + data: ["path": AnyCodable("/home")] + ) + + let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() + XCTAssertEqual(breadcrumbs.count, 1) + XCTAssertEqual(breadcrumbs.first?.category, "navigation") + XCTAssertEqual(breadcrumbs.first?.message, "Page loaded") + } + + func testMultipleBreadcrumbs() async { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + for i in 1...5 { + await DiagnosticsClient.shared.breadcrumb( + category: "test", + message: "Message \(i)" + ) + } + + let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() + XCTAssertEqual(breadcrumbs.count, 5) + } + + // MARK: - Tracing Tests + + func testSuccessfulTrace() async throws { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + let result = try await DiagnosticsClient.shared.trace(name: "test-operation") { + return 42 + } + + XCTAssertEqual(result, 42) + } + + func testFailingTrace() async { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + do { + _ = try await DiagnosticsClient.shared.trace(name: "failing-operation") { + throw TestError.test + } + XCTFail("Should have thrown") + } catch { + // Expected + XCTAssertTrue(error is TestError) + } + } + + // MARK: - Logging Tests + + func testLogAdding() async { + let config = DiagnosticsConfiguration( + productId: "test-app", + anonymousInstallId: "install-123", + platform: "ios", + channel: "ios_app", + osFamily: "ios", + appVersion: "1.0.0", + buildNumber: "100", + releaseChannel: "beta", + serverUrl: "https://api.test.com" + ) + + await DiagnosticsClient.shared.configure(config) + + await DiagnosticsClient.shared.log( + level: .info, + message: "Test message", + module: "TestModule" + ) + + // Log is buffered, no immediate assertion possible + // But breadcrumb should be created + let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() + XCTAssertTrue(breadcrumbs.contains { $0.category == "log" }) + } + + // MARK: - Types Tests + + func testDiagnosticsSessionEncoding() throws { + let session = DiagnosticsSession( + id: "ds_test123", + productId: "test-app", + status: .active, + collectionLevel: .debug, + captureLogs: true, + captureNetwork: true, + captureScreenshots: false, + screenshotOnError: true, + maxDurationMinutes: 60, + createdAt: "2026-03-03T12:00:00Z", + expiresAt: "2026-03-03T13:00:00Z" + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(session) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(DiagnosticsSession.self, from: data) + + XCTAssertEqual(decoded.id, session.id) + XCTAssertEqual(decoded.status, session.status) + XCTAssertEqual(decoded.collectionLevel, session.collectionLevel) + } + + func testLogEntryCreation() { + let entry = DiagnosticsLogEntry( + level: .error, + message: "Something went wrong", + timestamp: "2026-03-03T12:00:00Z", + module: "TestModule", + file: "Test.swift", + line: 42, + function: "testFunction", + context: ["key": AnyCodable("value")], + correlationId: "corr-123" + ) + + XCTAssertEqual(entry.level, .error) + XCTAssertEqual(entry.message, "Something went wrong") + XCTAssertEqual(entry.module, "TestModule") + XCTAssertEqual(entry.line, 42) + XCTAssertEqual(entry.correlationId, "corr-123") + } + + func testTraceSpanCreation() { + let span = DiagnosticsTraceSpan( + spanId: "span-123", + parentId: "parent-456", + name: "test-span", + kind: .internal, + startTime: "2026-03-03T12:00:00Z", + endTime: "2026-03-03T12:00:01Z", + durationMs: 1000, + attributes: ["key": AnyCodable("value")], + status: .ok, + statusMessage: nil + ) + + XCTAssertEqual(span.spanId, "span-123") + XCTAssertEqual(span.parentId, "parent-456") + XCTAssertEqual(span.name, "test-span") + XCTAssertEqual(span.status, .ok) + } + + func testBreadcrumbCreation() { + let breadcrumb = DiagnosticsBreadcrumb( + timestamp: "2026-03-03T12:00:00Z", + category: "navigation", + message: "User tapped button", + data: ["buttonId": AnyCodable("submit")] + ) + + XCTAssertEqual(breadcrumb.category, "navigation") + XCTAssertEqual(breadcrumb.message, "User tapped button") + XCTAssertNotNil(breadcrumb.data) + } + + func testAnyCodableEncoding() throws { + let value = AnyCodable("test-string") + let encoder = JSONEncoder() + let data = try encoder.encode(value) + + // Should not throw + XCTAssertFalse(data.isEmpty) + } + + func testAnyCodableIntEncoding() throws { + let value = AnyCodable(42) + let encoder = JSONEncoder() + let data = try encoder.encode(value) + + XCTAssertFalse(data.isEmpty) + } + + func testAnyCodableBoolEncoding() throws { + let value = AnyCodable(true) + let encoder = JSONEncoder() + let data = try encoder.encode(value) + + XCTAssertFalse(data.isEmpty) + } +} + +enum TestError: Error { + case test +} diff --git a/vendor/bytelyst/swift-platform-sdk/Package.swift b/vendor/bytelyst/swift-platform-sdk/Package.swift new file mode 100644 index 0000000..210cceb --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +// ByteLystPlatformSDK — Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. +// Lives in learning_ai_common_plat so every product app references ONE source of truth. + +import PackageDescription + +let package = Package( + name: "ByteLystPlatformSDK", + platforms: [ + .iOS(.v17), + .watchOS(.v10), + .macOS(.v14), + ], + products: [ + .library( + name: "ByteLystPlatformSDK", + targets: ["ByteLystPlatformSDK"] + ), + ], + targets: [ + .target( + name: "ByteLystPlatformSDK", + path: "Sources" + ), + .testTarget( + name: "ByteLystPlatformSDKTests", + dependencies: ["ByteLystPlatformSDK"], + path: "Tests" + ), + ] +) diff --git a/vendor/bytelyst/swift-platform-sdk/README.md b/vendor/bytelyst/swift-platform-sdk/README.md new file mode 100644 index 0000000..e500e78 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/README.md @@ -0,0 +1,215 @@ +# ByteLystPlatformSDK + +Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. Eliminates code duplication across products by providing a single source of truth for platform-service integration. + +## What's Inside + +| File | What It Does | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `BLPlatformConfig` | Product-specific configuration (productId, baseURL, bundleId, appGroupId) | +| `BLPlatformClient` | Generic HTTP client with auth injection, x-request-id, timeout | +| `BLKeychain` | Keychain CRUD for secure token storage | +| `BLTelemetryClient` | Telemetry event queue + batch flush (matches `@bytelyst/telemetry-client`) | +| `BLAuthClient` | Auth operations: login, register, refresh, password ops, email verify, delete account (matches `@bytelyst/auth-client`) | +| `BLFeatureFlagClient` | Feature flag polling from platform-service `/api/flags/poll` | +| `BLSyncEngine` | Generic offline-first sync engine with delta pull + batch push | +| `BLBlobClient` | Azure Blob Storage upload via SAS tokens from platform-service | +| `BLKillSwitchClient` | Kill switch check from platform-service (fail-open) | +| `BLLicenseClient` | License key activation + status via platform-service | +| `BLBiometricAuth` | Face ID / Touch ID wrapper (LocalAuthentication) | +| `BLCrashReporter` | MetricKit crash and hang reporting with local storage | +| `BLAuditLogger` | Local rotating JSON audit log for debugging | + +## Usage + +### 1. Add to Xcode Project + +In Xcode: **File → Add Package Dependencies → Add Local...** → select this directory: + +``` +../learning_ai_common_plat/packages/swift-platform-sdk/ +``` + +Or in `Package.swift`: + +```swift +.package(path: "../learning_ai_common_plat/packages/swift-platform-sdk") +``` + +### 2. Configure at App Launch + +```swift +import ByteLystPlatformSDK + +// Create config — one per app +let config = BLPlatformConfig.fromInfoPlist( + productId: "peakpulse", + defaultBaseURL: "https://api.peakpulse.app", + bundleId: "com.saravana.peakpulse" +) + +// Create shared HTTP client +let client = BLPlatformClient(config: config) + +// Create services +let telemetry = BLTelemetryClient(config: config, client: client) +let auth = BLAuthClient(config: config, client: client) +let flags = BLFeatureFlagClient(config: config, client: client) +``` + +### 3. Telemetry + +```swift +telemetry.start() +telemetry.trackEvent("info", module: "session", name: "session_started", metrics: ["elevation": 2450.0]) +telemetry.trackScreen("live_tracking") +// On app background: +telemetry.stop() +``` + +### 4. Auth + +```swift +// Login +let user = try await auth.login(email: "user@example.com", password: "secret") + +// Restore on launch +await auth.restoreSession() + +// Listen for state changes +auth.onAuthStateChanged = { state in + switch state { + case .loggedIn(let user): print("Hello, \(user.displayName)") + case .loggedOut: print("Signed out") + case .error(let msg): print("Auth error: \(msg)") + case .loading: print("Loading...") + } +} +``` + +### 5. Feature Flags + +```swift +flags.start(userId: auth.accessToken != nil ? "user-id" : nil) +if flags.isEnabled("peakpulse.pro_charts") { + // Show Pro charts +} +``` + +### 6. Sync Engine + +```swift +// Implement your product-specific adapter +struct PeakSessionSyncAdapter: BLSyncAdapter { + typealias SyncItem = PeakSessionDTO + + func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [PeakSessionDTO] { + var path = "/api/peak-sessions/sync" + if let since { path += "?since=\(ISO8601DateFormatter().string(from: since))" } + return try await client.request(path: path, responseType: [PeakSessionDTO].self) + } + + func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult { + let body = try JSONEncoder().encode(["items": items.compactMap(\.payload)]) + let (data, _) = try await client.rawRequest(path: "/api/peak-sessions/batch", method: "POST", body: body) + return try JSONDecoder().decode(BLBatchResult.self, from: data) + } +} + +let syncEngine = BLSyncEngine( + config: config, + client: client, + adapter: PeakSessionSyncAdapter() +) +``` + +## Product Apps Using This SDK + +| Product | Repo | Wrappers | Status | +| ---------- | ----------------------------------- | -------- | ----------------------------------- | +| ChronoMind | `learning_ai_clock` | 5 files | ✅ Migrated (Cloud/ + Diagnostics/) | +| LysnrAI | `learning_voice_ai_agent` | 9 files | ✅ Migrated (Auth/ + Util/) | +| MindLyst | `learning_multimodal_memory_agents` | 4 files | ✅ Migrated (Services/) | +| PeakPulse | `learning_ai_peakpulse` | — | New — will use SDK from day one | +| NomGap | `learning_ai_fastgap` | — | React Native — uses TS packages | + +## What This Replaces + +Before this SDK, each iOS app had its own copy of platform integration code: + +| ChronoMind (old) | LysnrAI (old) | MindLyst (old) | SDK (new) | +| --------------------------------- | ------------------------------- | ------------------------------- | --------------------- | +| `KeychainHelper` (53 lines) | `KeychainHelper` (60 lines) | `KeychainHelper` (60 lines) | `BLKeychain` | +| `CMTelemetryService` (139 lines) | `TelemetryService` (288 lines) | `TelemetryService` (139 lines) | `BLTelemetryClient` | +| `CMAuthService` (359 lines) | `AuthService` (421 lines) | `AuthService` (389 lines) | `BLAuthClient` | +| `FeatureFlagService` (72 lines) | `FeatureFlagService` (71 lines) | `FeatureFlagService` (72 lines) | `BLFeatureFlagClient` | +| `CrashReporter` (153 lines) | — | — | `BLCrashReporter` | +| — | `BlobService` (118 lines) | — | `BLBlobClient` | +| — | `KillSwitchService` (48 lines) | — | `BLKillSwitchClient` | +| — | `LicenseService` (135 lines) | — | `BLLicenseClient` | +| — | `BiometricAuth` (65 lines) | — | `BLBiometricAuth` | +| — | `AuditLogger` (70 lines) | — | `BLAuditLogger` | +| `PlatformSyncManager` (450 lines) | Various sync files | — | `BLSyncEngine` | + +Total duplicated code eliminated: **~2,600+ lines across 3 product apps**. + +## Design Decisions + +1. **No `@MainActor`** — the SDK is thread-safe via NSLock. Product apps can wrap in `@MainActor` at the view model layer. +2. **No singletons** — product apps own the lifecycle. Create instances at app launch, inject where needed. +3. **No SwiftUI dependency** — pure Foundation. Works in watchOS, macOS, widgets, extensions. +4. **Protocol-based sync** — `BLSyncAdapter` lets each product define its own DTOs and endpoints while reusing the queue/timer/conflict plumbing. +5. **Fire-and-forget telemetry** — errors never surface to the user. Matches the TypeScript package behavior. + +## Platforms + +- iOS 17+ +- watchOS 10+ +- macOS 14+ + +## Broadcast & Survey + +New in v1.2: In-app messaging and survey capabilities. + +### Broadcast Client + +```swift +import ByteLystPlatformSDK + +let config = BLPlatformConfig( + productId: "lysnrai", + baseURL: URL(string: "https://api.bytelyst.io/v1")!, + getAuthToken: { await getToken() } +) + +let broadcastClient = BLBroadcastClient(config: config) + +// Start polling for messages +broadcastClient.startPolling(intervalMs: 60000) { messages in + // Handle new messages +} + +// SwiftUI UI components +BLInAppMessageBanner(client: broadcastClient, position: .top) +BLBroadcastModal(client: broadcastClient) +``` + +### Survey Client + +```swift +let surveyClient = BLSurveyClient(config: config) + +// Check for active surveys +let (survey, _) = await surveyClient.getActiveSurvey() + +// Start and complete survey +await surveyClient.startSurvey(surveyId: survey.id) +await surveyClient.submitAnswer(surveyId: survey.id, questionId: "q1", answer: answer) +await surveyClient.completeSurvey(surveyId: survey.id) + +// SwiftUI modal +BLSurveyModal(client: surveyClient) +``` + +See [Broadcast & Survey Guide](BROADCAST_SURVEY_GUIDE.md) for full documentation. + diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift new file mode 100644 index 0000000..89c4802 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift @@ -0,0 +1,82 @@ +// ── Audit Logger ──────────────────────────────────────────── +// Generic local audit logger that tracks user actions for debugging. +// Stores events in a rotating JSON file (configurable max entries). +// Product apps configure with a product-specific file name. + +import Foundation + +/// Audit event stored locally. +public struct BLAuditEvent: Codable, Sendable { + public let id: String + public let action: String + public let details: String? + public let timestamp: Date + + public init(action: String, details: String? = nil) { + self.id = UUID().uuidString + self.action = action + self.details = details + self.timestamp = Date() + } +} + +/// Generic local audit logger for all ByteLyst iOS apps. +/// Stores events in a rotating JSON file in the Documents directory. +public enum BLAuditLogger { + + private static var maxEvents = 1000 + private static var fileName = "audit_log.json" + + /// Configure the logger with a product-specific file name and max events. + public static func configure(fileName: String = "audit_log.json", maxEvents: Int = 1000) { + self.fileName = fileName + self.maxEvents = maxEvents + } + + private static var fileURL: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return docs.appendingPathComponent(fileName) + } + + /// Log a user action. + public static func log(_ action: String, details: String? = nil) { + let event = BLAuditEvent(action: action, details: details) + + var events = loadEvents() + events.append(event) + + // Rotate: keep only the most recent maxEvents + if events.count > maxEvents { + events = Array(events.suffix(maxEvents)) + } + + saveEvents(events) + } + + /// Get all logged events (newest first). + public static func getEvents(limit: Int = 100) -> [BLAuditEvent] { + let events = loadEvents() + return Array(events.suffix(limit).reversed()) + } + + /// Clear all audit logs. + public static func clear() { + try? FileManager.default.removeItem(at: fileURL) + } + + // MARK: - Persistence + + private static func loadEvents() -> [BLAuditEvent] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([BLAuditEvent].self, from: data)) ?? [] + } + + private static func saveEvents(_ events: [BLAuditEvent]) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(events) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift new file mode 100644 index 0000000..89b0616 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift @@ -0,0 +1,665 @@ +// ── Auth Client ────────────────────────────────────────────── +// Generic auth client matching @bytelyst/auth-client TypeScript interface. +// Login, register, refresh, forgot/reset/change password, verify email, delete account. +// SmartAuth v2: social login, MFA (TOTP), passkeys, device trust. +// Token storage via BLKeychain. Product apps configure via BLPlatformConfig. + +import Foundation + +// MARK: - Public Types + +public struct BLAuthUser: Codable, Sendable { + public let id: String + public let email: String + public let displayName: String + public let plan: String + public let role: String + + enum CodingKeys: String, CodingKey { + case id, email, displayName, plan, role + } + + public init(id: String, email: String, displayName: String, plan: String = "free", role: String = "user") { + self.id = id + self.email = email + self.displayName = displayName + self.plan = plan + self.role = role + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + email = try c.decode(String.self, forKey: .email) + displayName = try c.decode(String.self, forKey: .displayName) + plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + } +} + +public enum BLAuthState: Sendable { + case loading + case loggedOut + case loggedIn(BLAuthUser) + case mfaRequired(BLMfaChallenge) + case error(String) +} + +// MARK: - SmartAuth Types + +/// MFA challenge returned when login requires multi-factor verification. +public struct BLMfaChallenge: Codable, Sendable { + public let mfaRequired: Bool + public let mfaChallenge: String + public let methods: [String] + + public init(mfaRequired: Bool, mfaChallenge: String, methods: [String]) { + self.mfaRequired = mfaRequired + self.mfaChallenge = mfaChallenge + self.methods = methods + } +} + +/// TOTP setup response with secret URI and recovery codes. +public struct BLTotpSetup: Codable, Sendable { + public let otpauthUri: String + public let qrCode: String + public let recoveryCodes: [String] +} + +/// MFA status for the current user. +public struct BLMfaStatus: Codable, Sendable { + public let mfaEnabled: Bool + public let methods: [String] + public let recoveryCodesRemaining: Int +} + +/// Linked OAuth provider. +public struct BLAuthProvider: Codable, Sendable { + public let provider: String + public let email: String + public let linkedAt: String + public let lastUsedAt: String? +} + +/// Passkey metadata. +public struct BLPasskey: Codable, Sendable { + public let id: String + public let friendlyName: String + public let deviceType: String + public let lastUsedAt: String? + public let createdAt: String +} + +/// Trusted/remembered device. +public struct BLDevice: Codable, Sendable { + public let fingerprint: String + public let trustLevel: String + public let deviceInfo: DeviceInfo? + public let lastIp: String? + public let lastLocation: String? + public let trustExpiresAt: String? + public let createdAt: String + public let lastSeenAt: String + public let isTrusted: Bool + + public struct DeviceInfo: Codable, Sendable { + public let userAgent: String? + public let platform: String? + public let model: String? + public let os: String? + } + + /// Convenience: display name derived from device info. + public var name: String { + deviceInfo?.model ?? deviceInfo?.platform ?? fingerprint.prefix(8).description + } + + /// Convenience: platform string. + public var platform: String { + deviceInfo?.platform ?? "unknown" + } + + /// Stable identifier for SwiftUI ForEach. + public var id: String { fingerprint } +} + +/// Login event for security log. +public struct BLLoginEvent: Codable, Sendable { + public let id: String + public let eventType: String + public let method: String + public let ip: String + public let geo: BLGeo? + public let riskScore: Int + public let createdAt: String +} + +/// Geo location. +public struct BLGeo: Codable, Sendable { + public let country: String + public let city: String +} + +// MARK: - Phase 5C–5E Types + +/// Decrypted TOTP secret for local code generation in the auth app. +public struct BLTotpSecret: Codable, Sendable { + public let secret: String + public let issuer: String + public let accountName: String + public let digits: Int + public let period: Int + public let algorithm: String +} + +/// Pending push MFA approval. +public struct BLPushApproval: Codable, Sendable { + public let id: String + public let requestProductId: String + public let requestPlatform: String + public let requestIp: String + public let requestGeo: BLGeo? + public let createdAt: String + public let expiresAt: String +} + +/// Push approval response after approve/deny. +public struct BLPushApprovalResponse: Codable, Sendable { + public let id: String + public let status: String + public let respondedAt: String? +} + +/// QR challenge for desktop/TV login. +public struct BLQrChallenge: Codable, Sendable { + public let id: String + public let challengeToken: String + public let expiresAt: String +} + +/// QR challenge poll status. +public struct BLQrStatus: Codable, Sendable { + public let status: String + public let accessToken: String? + public let refreshToken: String? + public let user: BLAuthUser? +} + +// MARK: - Auth Errors + +/// Auth-specific errors for SmartAuth flows. +public enum BLAuthError: LocalizedError { + case mfaRequired(BLMfaChallenge) + + public var errorDescription: String? { + switch self { + case .mfaRequired: return "Multi-factor authentication required" + } + } +} + +// MARK: - Auth Client + +/// Generic auth client for all ByteLyst iOS apps. +/// Handles login, register, token refresh, password operations, and account management. +/// SmartAuth v2: social login, MFA, passkeys, device trust, step-up auth. +/// Stores tokens in Keychain. Notifies via `onAuthStateChanged` callback. +public final class BLAuthClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let keychainService: String + + /// Called whenever auth state changes. Set by the product app's view model / observable. + public var onAuthStateChanged: ((BLAuthState) -> Void)? + + /// Called when tokens are updated (for wiring into sync managers). + public var onTokensUpdated: ((String?) -> Void)? + + private var refreshTimer: Timer? + + private struct TokenResponse: Codable { + let accessToken: String + let refreshToken: String + let user: BLAuthUser + } + + private struct RefreshResponse: Codable { + let accessToken: String + let refreshToken: String + } + + private struct MessageResponse: Codable { + let message: String + } + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + self.keychainService = config.bundleId + } + + // MARK: - Token Access + + public var accessToken: String? { + BLKeychain.read(service: keychainService, key: "access_token") + } + + public var refreshTokenValue: String? { + BLKeychain.read(service: keychainService, key: "refresh_token") + } + + public var isAuthenticated: Bool { + accessToken != nil && !(accessToken?.isEmpty ?? true) + } + + // MARK: - Auth Operations + + /// Login with email and password. + /// If MFA is enabled, throws `BLAuthError.mfaRequired` with the challenge. + public func login(email: String, password: String) async throws -> BLAuthUser { + let body: [String: String] = [ + "email": email, + "password": password, + "productId": config.productId, + ] + let (data, _) = try await client.rawRequest(path: "/api/auth/login", method: "POST", body: body) + // Check for MFA challenge response + if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), + challenge.mfaRequired { + onAuthStateChanged?(.mfaRequired(challenge)) + throw BLAuthError.mfaRequired(challenge) + } + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + onAuthStateChanged?(.loggedIn(result.user)) + return result.user + } + + /// Register a new account. + public func register(displayName: String, email: String, password: String) async throws -> BLAuthUser { + let body: [String: String] = [ + "displayName": displayName, + "email": email, + "password": password, + "productId": config.productId, + ] + let (data, _) = try await client.rawRequest(path: "/api/auth/register", method: "POST", body: body) + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + onAuthStateChanged?(.loggedIn(result.user)) + return result.user + } + + /// Fetch current user profile. + public func getMe() async throws -> BLAuthUser { + return try await client.request(path: "/api/auth/me", responseType: BLAuthUser.self) + } + + /// Refresh the access token using the stored refresh token. + @discardableResult + public func refreshAccessToken() async -> Bool { + guard let rt = refreshTokenValue, !rt.isEmpty else { return false } + let body = ["refreshToken": rt] + + do { + let (data, _) = try await client.rawRequest(path: "/api/auth/refresh", method: "POST", body: body) + let result = try JSONDecoder().decode(RefreshResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + return true + } catch { + if let netErr = error as? BLNetworkError, netErr.statusCode == 401 { + logout() + } + return false + } + } + + /// Request password reset email. + public func forgotPassword(email: String) async throws { + let body = ["email": email, "productId": config.productId] + _ = try await client.rawRequest(path: "/api/auth/forgot-password", method: "POST", body: body) + } + + /// Reset password with token. + public func resetPassword(token: String, newPassword: String) async throws { + let body = ["token": token, "newPassword": newPassword] + _ = try await client.rawRequest(path: "/api/auth/reset-password", method: "POST", body: body) + } + + /// Change password (authenticated). + public func changePassword(currentPassword: String, newPassword: String) async throws { + let body = ["currentPassword": currentPassword, "newPassword": newPassword] + _ = try await client.rawRequest(path: "/api/auth/change-password", method: "POST", body: body) + } + + /// Verify email with token. + public func verifyEmail(token: String) async throws { + let body = ["token": token] + _ = try await client.rawRequest(path: "/api/auth/verify-email", method: "POST", body: body) + } + + /// Resend verification email. + public func resendVerification(email: String) async throws { + let body = ["email": email, "productId": config.productId] + _ = try await client.rawRequest(path: "/api/auth/resend-verification", method: "POST", body: body) + } + + /// Delete account (requires password confirmation). + public func deleteAccount(password: String) async throws { + let body = ["password": password] + _ = try await client.rawRequest(path: "/api/auth/account", method: "DELETE", body: body) + logout() + } + + /// Logout — clear tokens and notify. + public func logout() { + stopRefreshTimer() + clearTokens() + onAuthStateChanged?(.loggedOut) + } + + // MARK: - Social Login (SmartAuth v2) + + /// Login with Google id_token. + public func loginWithGoogle(idToken: String) async throws -> BLAuthUser { + return try await socialLogin(provider: "google", idToken: idToken) + } + + /// Login with Microsoft id_token. + public func loginWithMicrosoft(idToken: String) async throws -> BLAuthUser { + return try await socialLogin(provider: "microsoft", idToken: idToken) + } + + /// Login with Apple id_token. + public func loginWithApple(idToken: String) async throws -> BLAuthUser { + return try await socialLogin(provider: "apple", idToken: idToken) + } + + /// Generic social login — sends id_token to /auth/oauth/{provider}. + private func socialLogin(provider: String, idToken: String) async throws -> BLAuthUser { + let body: [String: String] = ["idToken": idToken, "productId": config.productId] + let (data, _) = try await client.rawRequest( + path: "/api/auth/oauth/\(provider)", + method: "POST", + body: body + ) + // Server may return MFA challenge or tokens + if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), + challenge.mfaRequired { + onAuthStateChanged?(.mfaRequired(challenge)) + throw BLAuthError.mfaRequired(challenge) + } + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + onAuthStateChanged?(.loggedIn(result.user)) + return result.user + } + + // MARK: - MFA (SmartAuth v2) + + /// Verify MFA challenge (TOTP code or recovery code). + public func verifyMfa(challengeToken: String, code: String, method: String = "totp") async throws -> BLAuthUser { + let body: [String: String] = [ + "challengeToken": challengeToken, + "code": code, + "method": method, + ] + let (data, _) = try await client.rawRequest(path: "/api/auth/mfa/verify", method: "POST", body: body) + let result = try JSONDecoder().decode(TokenResponse.self, from: data) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + return result.user + } + + /// Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. + public func setupTotp() async throws -> BLTotpSetup { + return try await client.request(path: "/api/auth/mfa/totp/setup", method: "POST", responseType: BLTotpSetup.self) + } + + /// Verify TOTP setup with a code from the authenticator app. + public func verifyTotpSetup(code: String) async throws { + let body = ["code": code] + _ = try await client.rawRequest(path: "/api/auth/mfa/totp/verify-setup", method: "POST", body: body) + } + + /// Disable MFA (requires step-up token via X-Step-Up-Token header). + public func disableMfa() async throws { + _ = try await client.rawRequest(path: "/api/auth/mfa/totp", method: "DELETE") + } + + /// Get current MFA status. + public func getMfaStatus() async throws -> BLMfaStatus { + return try await client.request(path: "/api/auth/mfa/status", responseType: BLMfaStatus.self) + } + + /// Regenerate recovery codes (requires step-up). + public func regenerateRecoveryCodes() async throws -> [String] { + struct CodesResponse: Codable { let recoveryCodes: [String] } + let result = try await client.request( + path: "/api/auth/mfa/recovery/regenerate", + method: "POST", + responseType: CodesResponse.self + ) + return result.recoveryCodes + } + + // MARK: - Providers (SmartAuth v2) + + /// List linked OAuth providers. + public func getProviders() async throws -> [BLAuthProvider] { + return try await client.request(path: "/api/auth/providers", responseType: [BLAuthProvider].self) + } + + /// Link an OAuth provider to the current account. + public func linkProvider(provider: String, idToken: String) async throws { + let body = ["provider": provider, "idToken": idToken] + _ = try await client.rawRequest(path: "/api/auth/providers/link", method: "POST", body: body) + } + + /// Unlink an OAuth provider. + public func unlinkProvider(provider: String) async throws { + _ = try await client.rawRequest(path: "/api/auth/providers/\(provider)", method: "DELETE") + } + + // MARK: - Passkeys (SmartAuth v2) + + /// Get passkey registration options from server. + public func getPasskeyRegistrationOptions() async throws -> Data { + let (data, _) = try await client.rawRequest( + path: "/api/auth/passkeys/register/options", + method: "POST" + ) + return data + } + + /// Verify passkey registration with attestation response. + public func verifyPasskeyRegistration(attestation: [String: Any], friendlyName: String) async throws { + var payload = attestation + payload["friendlyName"] = friendlyName + let jsonData = try JSONSerialization.data(withJSONObject: payload) + // Wrap raw JSON data in a Codable struct for BLPlatformClient + _ = try await client.rawRequest( + path: "/api/auth/passkeys/register/verify", + method: "POST", + rawBody: jsonData + ) + } + + /// Get passkey authentication options from server. + public func getPasskeyAuthenticationOptions() async throws -> Data { + let (data, _) = try await client.rawRequest( + path: "/api/auth/passkeys/authenticate/options", + method: "POST" + ) + return data + } + + /// Verify passkey authentication with assertion response. + public func verifyPasskeyAuthentication(assertion: [String: Any]) async throws -> BLAuthUser { + let jsonData = try JSONSerialization.data(withJSONObject: assertion) + let (responseData, _) = try await client.rawRequest( + path: "/api/auth/passkeys/authenticate/verify", + method: "POST", + rawBody: jsonData + ) + let result = try JSONDecoder().decode(TokenResponse.self, from: responseData) + saveTokens(access: result.accessToken, refresh: result.refreshToken) + startRefreshTimer() + onAuthStateChanged?(.loggedIn(result.user)) + return result.user + } + + /// List registered passkeys. + public func listPasskeys() async throws -> [BLPasskey] { + return try await client.request(path: "/api/auth/passkeys", responseType: [BLPasskey].self) + } + + /// Delete a passkey (requires step-up). + public func deletePasskey(passkeyId: String) async throws { + _ = try await client.rawRequest(path: "/api/auth/passkeys/\(passkeyId)", method: "DELETE") + } + + // MARK: - Devices (SmartAuth v2) + + /// List devices for current user. + public func listDevices() async throws -> [BLDevice] { + struct DevicesResponse: Codable { let devices: [BLDevice] } + let result = try await client.request(path: "/api/auth/devices", responseType: DevicesResponse.self) + return result.devices + } + + /// Trust the current device (promotes to trusted, skips MFA for 90 days). + public func trustDevice() async throws { + _ = try await client.rawRequest(path: "/api/auth/devices/trust", method: "POST") + } + + /// Revoke trust on a specific device by fingerprint. + public func revokeDevice(fingerprint: String) async throws { + _ = try await client.rawRequest(path: "/api/auth/devices/\(fingerprint)", method: "DELETE") + } + + /// Revoke all device trust. + public func revokeAllDevices() async throws { + _ = try await client.rawRequest(path: "/api/auth/devices/revoke-all", method: "POST") + } + + // MARK: - Step-Up Auth (SmartAuth v2) + + /// Perform step-up authentication. Returns a short-lived step-up token. + public func stepUp(method: String, credential: String) async throws -> String { + let body = ["method": method, "credential": credential] + struct StepUpResponse: Codable { let stepUpToken: String } + let result = try await client.request( + path: "/api/auth/step-up", + method: "POST", + body: body, + responseType: StepUpResponse.self + ) + return result.stepUpToken + } + + // MARK: - Login History (SmartAuth v2) + + /// Get login events for the current user. + public func getLoginHistory(limit: Int = 20) async throws -> [BLLoginEvent] { + struct EventsResponse: Codable { let events: [BLLoginEvent] } + let result = try await client.request( + path: "/api/auth/login-events?limit=\(limit)", + responseType: EventsResponse.self + ) + return result.events + } + + // MARK: - TOTP Secret Retrieval (Phase 5C) + + /// Get the decrypted TOTP secret for local code generation (auth app). + public func getTotpSecret() async throws -> BLTotpSecret { + return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self) + } + + // MARK: - Push Approvals (Phase 5D) + + /// List pending push MFA approvals for the current user. + public func getPendingApprovals() async throws -> [BLPushApproval] { + return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self) + } + + /// Respond to a push MFA approval (approve or deny). + public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse { + let body = ["action": action] + return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self) + } + + // MARK: - QR Auth (Phase 5E) + + /// Confirm a QR login challenge from the auth app. + public func confirmQrLogin(challengeToken: String) async throws { + let body = ["challengeToken": challengeToken] + _ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body) + } + + /// Restore session from stored tokens. Call on app launch. + public func restoreSession() async { + guard isAuthenticated else { + onAuthStateChanged?(.loggedOut) + return + } + + onAuthStateChanged?(.loading) + + do { + let user = try await getMe() + onAuthStateChanged?(.loggedIn(user)) + startRefreshTimer() + } catch { + // Try refresh + let ok = await refreshAccessToken() + if ok { + do { + let user = try await getMe() + onAuthStateChanged?(.loggedIn(user)) + startRefreshTimer() + } catch { + onAuthStateChanged?(.loggedOut) + } + } else { + onAuthStateChanged?(.loggedOut) + } + } + } + + // MARK: - Private + + private func saveTokens(access: String, refresh: String) { + BLKeychain.save(service: keychainService, key: "access_token", value: access) + BLKeychain.save(service: keychainService, key: "refresh_token", value: refresh) + client.authToken = access + onTokensUpdated?(access) + } + + private func clearTokens() { + BLKeychain.delete(service: keychainService, key: "access_token") + BLKeychain.delete(service: keychainService, key: "refresh_token") + client.authToken = nil + onTokensUpdated?(nil) + } + + private func startRefreshTimer() { + stopRefreshTimer() + // Refresh every 12 minutes (access tokens expire in 15 minutes per PRD) + refreshTimer = Timer.scheduledTimer(withTimeInterval: 12 * 60, repeats: true) { [weak self] _ in + guard let self else { return } + Task { await self.refreshAccessToken() } + } + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift new file mode 100644 index 0000000..4a86ecb --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift @@ -0,0 +1,740 @@ +// ── Auth UI Kit ───────────────────────────────────────────── +// Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps. +// BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet. +// Themed via @Environment injection — always matches host product. + +import SwiftUI +import os + +#if canImport(AuthenticationServices) +import AuthenticationServices +#endif + +private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI") + +// MARK: - Auth UI Configuration + +/// Configuration for BLAuthUI views — passed via Environment. +public struct BLAuthUIConfig { + public let productName: String + public let accentColor: Color + public let backgroundColor: Color + public let textColor: Color + public let secondaryTextColor: Color + public let cardColor: Color + public let enabledProviders: [BLAuthUIProvider] + + public init( + productName: String = "ByteLyst", + accentColor: Color = .blue, + backgroundColor: Color = Color(.systemBackground), + textColor: Color = .primary, + secondaryTextColor: Color = .secondary, + cardColor: Color = Color(.secondarySystemBackground), + enabledProviders: [BLAuthUIProvider] = [.google, .apple] + ) { + self.productName = productName + self.accentColor = accentColor + self.backgroundColor = backgroundColor + self.textColor = textColor + self.secondaryTextColor = secondaryTextColor + self.cardColor = cardColor + self.enabledProviders = enabledProviders + } +} + +/// OAuth providers supported by BLAuthUI. +public enum BLAuthUIProvider: String, CaseIterable, Sendable { + case google + case microsoft + case apple +} + +// MARK: - Environment Key + +private struct BLAuthUIConfigKey: EnvironmentKey { + static let defaultValue = BLAuthUIConfig() +} + +extension EnvironmentValues { + public var blAuthUIConfig: BLAuthUIConfig { + get { self[BLAuthUIConfigKey.self] } + set { self[BLAuthUIConfigKey.self] = newValue } + } +} + +// MARK: - BLLoginView + +/// Full login view with email/password + social buttons + passkey option. +/// Host product injects theme via `.environment(\.blAuthUIConfig, config)`. +public struct BLLoginView: View { + @Environment(\.blAuthUIConfig) private var config + + @State private var email = "" + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + + /// Called with email + password when user taps Sign In. + public var onLogin: (String, String) async throws -> Void + /// Called with provider name when user taps a social button. + public var onSocialLogin: (BLAuthUIProvider) -> Void + /// Called when user taps "Use Passkey". + public var onPasskeyLogin: (() -> Void)? + /// Called when user taps "Forgot Password?". + public var onForgotPassword: (() -> Void)? + /// Called when user taps "Create Account". + public var onCreateAccount: (() -> Void)? + + public init( + onLogin: @escaping (String, String) async throws -> Void, + onSocialLogin: @escaping (BLAuthUIProvider) -> Void, + onPasskeyLogin: (() -> Void)? = nil, + onForgotPassword: (() -> Void)? = nil, + onCreateAccount: (() -> Void)? = nil + ) { + self.onLogin = onLogin + self.onSocialLogin = onSocialLogin + self.onPasskeyLogin = onPasskeyLogin + self.onForgotPassword = onForgotPassword + self.onCreateAccount = onCreateAccount + } + + public var body: some View { + ScrollView { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text("Sign in to \(config.productName)") + .font(.title2.bold()) + .foregroundColor(config.textColor) + Text("Welcome back") + .font(.subheadline) + .foregroundColor(config.secondaryTextColor) + } + .padding(.top, 40) + + // Social Buttons + if !config.enabledProviders.isEmpty { + VStack(spacing: 12) { + ForEach(config.enabledProviders, id: \.self) { provider in + socialButton(for: provider) + } + } + + dividerRow + } + + // Email / Password + VStack(spacing: 16) { + TextField("Email", text: $email) + .textFieldStyle(.roundedBorder) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + .textContentType(.password) + } + + // Error + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .multilineTextAlignment(.center) + } + + // Sign In Button + Button { + Task { await performLogin() } + } label: { + HStack { + if isLoading { + ProgressView() + .tint(.white) + } + Text("Sign In") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(email.isEmpty || password.isEmpty || isLoading) + + // Passkey + if let onPasskeyLogin { + Button { + logger.debug("Passkey login tapped") + onPasskeyLogin() + } label: { + HStack { + Image(systemName: "person.badge.key.fill") + Text("Sign in with Passkey") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.cardColor) + .foregroundColor(config.textColor) + .cornerRadius(10) + } + } + + // Forgot Password / Create Account + VStack(spacing: 12) { + if let onForgotPassword { + Button("Forgot Password?") { + onForgotPassword() + } + .font(.subheadline) + .foregroundColor(config.accentColor) + } + + if let onCreateAccount { + HStack { + Text("Don't have an account?") + .foregroundColor(config.secondaryTextColor) + Button("Create Account") { + onCreateAccount() + } + .foregroundColor(config.accentColor) + } + .font(.subheadline) + } + } + } + .padding(.horizontal, 24) + } + .background(config.backgroundColor.ignoresSafeArea()) + } + + private func performLogin() async { + isLoading = true + errorMessage = nil + do { + logger.debug("Attempting email/password login for \(email)") + try await onLogin(email, password) + } catch let error as BLAuthError { + // MFA required is not an error for the user — handled upstream + logger.info("MFA required for \(email)") + _ = error // Suppress unused warning + } catch { + logger.error("Login failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + } + isLoading = false + } + + @ViewBuilder + private func socialButton(for provider: BLAuthUIProvider) -> some View { + Button { + logger.debug("Social login: \(provider.rawValue)") + onSocialLogin(provider) + } label: { + HStack { + providerIcon(for: provider) + Text("Continue with \(providerDisplayName(provider))") + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.cardColor) + .foregroundColor(config.textColor) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + } + + @ViewBuilder + private func providerIcon(for provider: BLAuthUIProvider) -> some View { + switch provider { + case .google: + Image(systemName: "globe") + case .microsoft: + Image(systemName: "building.2") + case .apple: + Image(systemName: "applelogo") + } + } + + private func providerDisplayName(_ provider: BLAuthUIProvider) -> String { + switch provider { + case .google: return "Google" + case .microsoft: return "Microsoft" + case .apple: return "Apple" + } + } + + private var dividerRow: some View { + HStack { + Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) + Text("or") + .font(.caption) + .foregroundColor(config.secondaryTextColor) + Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) + } + } +} + +// MARK: - BLMfaChallengeView + +/// 6-digit TOTP code entry with countdown and recovery code fallback. +public struct BLMfaChallengeView: View { + @Environment(\.blAuthUIConfig) private var config + + @State private var code = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showRecovery = false + + public let challenge: BLMfaChallenge + public var onVerify: (String, String, String) async throws -> Void + public var onCancel: (() -> Void)? + + public init( + challenge: BLMfaChallenge, + onVerify: @escaping (String, String, String) async throws -> Void, + onCancel: (() -> Void)? = nil + ) { + self.challenge = challenge + self.onVerify = onVerify + self.onCancel = onCancel + } + + public var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 48)) + .foregroundColor(config.accentColor) + + Text("Two-Factor Authentication") + .font(.title3.bold()) + .foregroundColor(config.textColor) + + Text(showRecovery + ? "Enter a recovery code" + : "Enter the 6-digit code from your authenticator app") + .font(.subheadline) + .foregroundColor(config.secondaryTextColor) + .multilineTextAlignment(.center) + } + + // Code Input + TextField(showRecovery ? "Recovery Code" : "000000", text: $code) + .textFieldStyle(.roundedBorder) + .keyboardType(showRecovery ? .default : .numberPad) + .multilineTextAlignment(.center) + .font(.title2.monospaced()) + .frame(maxWidth: 200) + + // Error + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + + // Verify Button + Button { + Task { await verify() } + } label: { + HStack { + if isLoading { ProgressView().tint(.white) } + Text("Verify") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(code.isEmpty || isLoading) + + // Toggle recovery / cancel + VStack(spacing: 12) { + Button(showRecovery ? "Use authenticator code" : "Use recovery code") { + showRecovery.toggle() + code = "" + errorMessage = nil + } + .font(.subheadline) + .foregroundColor(config.accentColor) + + if let onCancel { + Button("Cancel") { onCancel() } + .font(.subheadline) + .foregroundColor(config.secondaryTextColor) + } + } + } + .padding(24) + .background(config.backgroundColor) + } + + private func verify() async { + isLoading = true + errorMessage = nil + let method = showRecovery ? "recovery" : "totp" + do { + logger.debug("Verifying MFA: method=\(method)") + try await onVerify(challenge.mfaChallenge, code, method) + } catch { + logger.error("MFA verify failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + code = "" + } + isLoading = false + } +} + +// MARK: - BLPasskeyView + +/// Passkey prompt with biometric hint text. +/// Triggers ASAuthorizationController for platform passkey authentication. +public struct BLPasskeyView: View { + @Environment(\.blAuthUIConfig) private var config + + @State private var isLoading = false + @State private var errorMessage: String? + + public var onAuthenticate: () async throws -> Void + public var onCancel: (() -> Void)? + + public init( + onAuthenticate: @escaping () async throws -> Void, + onCancel: (() -> Void)? = nil + ) { + self.onAuthenticate = onAuthenticate + self.onCancel = onCancel + } + + public var body: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Image(systemName: "person.badge.key.fill") + .font(.system(size: 48)) + .foregroundColor(config.accentColor) + + Text("Sign in with Passkey") + .font(.title3.bold()) + .foregroundColor(config.textColor) + + Text("Use Face ID, Touch ID, or your security key to sign in") + .font(.subheadline) + .foregroundColor(config.secondaryTextColor) + .multilineTextAlignment(.center) + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + + Button { + Task { await authenticate() } + } label: { + HStack { + if isLoading { ProgressView().tint(.white) } + Image(systemName: "faceid") + Text("Continue") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(isLoading) + + if let onCancel { + Button("Use another method") { onCancel() } + .font(.subheadline) + .foregroundColor(config.accentColor) + } + } + .padding(24) + .background(config.backgroundColor) + } + + private func authenticate() async { + isLoading = true + errorMessage = nil + do { + logger.debug("Starting passkey authentication") + try await onAuthenticate() + } catch { + logger.error("Passkey auth failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + } + isLoading = false + } +} + +// MARK: - BLStepUpSheet + +/// Re-authentication sheet for sensitive operations. +/// Supports password re-entry or biometric confirmation. +public struct BLStepUpSheet: View { + @Environment(\.blAuthUIConfig) private var config + @Environment(\.dismiss) private var dismiss + + @State private var password = "" + @State private var isLoading = false + @State private var errorMessage: String? + + public let reason: String + public var onStepUp: (String, String) async throws -> String + public var onComplete: (String) -> Void + + public init( + reason: String = "This action requires re-authentication", + onStepUp: @escaping (String, String) async throws -> String, + onComplete: @escaping (String) -> Void + ) { + self.reason = reason + self.onStepUp = onStepUp + self.onComplete = onComplete + } + + public var body: some View { + NavigationStack { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "lock.fill") + .font(.system(size: 36)) + .foregroundColor(config.accentColor) + + Text("Confirm Your Identity") + .font(.title3.bold()) + .foregroundColor(config.textColor) + + Text(reason) + .font(.subheadline) + .foregroundColor(config.secondaryTextColor) + .multilineTextAlignment(.center) + } + + SecureField("Password", text: $password) + .textFieldStyle(.roundedBorder) + .textContentType(.password) + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + + #if canImport(LocalAuthentication) + Button { + Task { await biometricStepUp() } + } label: { + HStack { + Image(systemName: "faceid") + Text("Use Biometrics") + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.cardColor) + .foregroundColor(config.textColor) + .cornerRadius(10) + } + #endif + + Button { + Task { await passwordStepUp() } + } label: { + HStack { + if isLoading { ProgressView().tint(.white) } + Text("Confirm") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(config.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(password.isEmpty || isLoading) + + Spacer() + } + .padding(24) + .background(config.backgroundColor) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + private func passwordStepUp() async { + isLoading = true + errorMessage = nil + do { + logger.debug("Step-up: password method") + let token = try await onStepUp("password", password) + onComplete(token) + dismiss() + } catch { + logger.error("Step-up failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + } + isLoading = false + } + + #if canImport(LocalAuthentication) + private func biometricStepUp() async { + let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity") + if success { + isLoading = true + errorMessage = nil + do { + logger.debug("Step-up: biometric method") + let token = try await onStepUp("biometric", "biometric_verified") + onComplete(token) + dismiss() + } catch { + logger.error("Biometric step-up failed: \(error.localizedDescription)") + errorMessage = error.localizedDescription + } + isLoading = false + } else { + errorMessage = "Biometric authentication failed" + } + } + #endif +} + +// ── BLDeviceListView ──────────────────────────────────────── + +/// Device management view — list trusted/remembered devices, revoke trust. +/// Mirrors the Kotlin `BLDeviceListScreen` for platform parity. +/// +/// - Parameters: +/// - devices: List of devices from `BLAuthClient.listDevices()`. +/// - onRevokeDevice: Called with device ID when user revokes a device. +/// - onRevokeAll: Called when user revokes all devices. `nil` hides the button. +/// - isLoading: Whether data is loading. +public struct BLDeviceListView: View { + public let devices: [BLDevice] + public let onRevokeDevice: (String) -> Void + public var onRevokeAll: (() -> Void)? + public var isLoading: Bool = false + + public init( + devices: [BLDevice], + onRevokeDevice: @escaping (String) -> Void, + onRevokeAll: (() -> Void)? = nil, + isLoading: Bool = false + ) { + self.devices = devices + self.onRevokeDevice = onRevokeDevice + self.onRevokeAll = onRevokeAll + self.isLoading = isLoading + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Your Devices") + .font(.title2.bold()) + Spacer() + if let onRevokeAll, !devices.isEmpty { + Button(role: .destructive, action: onRevokeAll) { + Text("Revoke All") + } + } + } + + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 32) + } else if devices.isEmpty { + Text("No devices found") + .foregroundStyle(.secondary) + .padding(.vertical, 16) + } else { + ForEach(devices, id: \.id) { device in + DeviceCardView(device: device) { + onRevokeDevice(device.id) + } + } + } + } + .padding() + } + } +} + +/// Individual device card within BLDeviceListView. +private struct DeviceCardView: View { + let device: BLDevice + let onRevoke: () -> Void + + private var platformIcon: String { + switch device.platform { + case "ios": return "iphone" + case "android": return "apps.iphone" + case "macos": return "laptopcomputer" + case "windows", "linux": return "desktopcomputer" + default: return "display" + } + } + + private var trustColor: Color { + switch device.trustLevel { + case "trusted": return .blue + case "remembered": return .green + default: return .secondary + } + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: platformIcon) + .font(.title2) + .foregroundStyle(trustColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(device.name) + .font(.body) + Text("\(device.trustLevel) · \(device.platform)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if device.trustLevel == "trusted" || device.trustLevel == "remembered" { + Button(role: .destructive, action: onRevoke) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift new file mode 100644 index 0000000..9435ec6 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift @@ -0,0 +1,77 @@ +// ── Biometric Authentication ──────────────────────────────── +// Generic Face ID / Touch ID wrapper using LocalAuthentication. +// Product apps pass a custom reason string for the biometric prompt. +// Not available on watchOS — guarded with #if canImport. + +import Foundation + +#if canImport(LocalAuthentication) +import LocalAuthentication + +/// Generic biometric authentication for all ByteLyst iOS/macOS apps. +/// Not available on watchOS (LocalAuthentication is iOS/macOS only). +public enum BLBiometricAuth { + + public enum BiometricType { + case faceID, touchID, none + } + + /// Check what biometric type is available on this device. + public static var availableType: BiometricType { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + return .none + } + switch context.biometryType { + case .faceID: return .faceID + case .touchID: return .touchID + default: return .none + } + } + + /// Whether biometric auth is available on this device. + public static var isAvailable: Bool { + availableType != .none + } + + /// Whether the user has enabled biometric lock in settings. + /// Uses a configurable UserDefaults key. + public static func isEnabled(key: String = "biometric_lock_enabled") -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + /// Set biometric lock enabled state. + public static func setEnabled(_ enabled: Bool, key: String = "biometric_lock_enabled") { + UserDefaults.standard.set(enabled, forKey: key) + } + + /// Authenticate with biometrics only. Returns true on success. + public static func authenticate(reason: String = "Unlock app") async -> Bool { + let context = LAContext() + context.localizedCancelTitle = "Use Password" + + do { + return try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason + ) + } catch { + return false + } + } + + /// Authenticate with biometrics or device passcode fallback. + public static func authenticateWithPasscode(reason: String = "Unlock app") async -> Bool { + let context = LAContext() + do { + return try await context.evaluatePolicy( + .deviceOwnerAuthentication, + localizedReason: reason + ) + } catch { + return false + } + } +} +#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift new file mode 100644 index 0000000..a211d32 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift @@ -0,0 +1,86 @@ +// ── Blob Storage Client ───────────────────────────────────── +// Generic Azure Blob Storage client via platform-service SAS tokens. +// Upload files to Azure Blob using SAS URL from POST /api/blob/sas. +// Product apps configure with BLPlatformConfig. + +import Foundation + +/// Response from platform-service SAS token endpoint. +public struct BLSASResponse: Codable, Sendable { + public let sasUrl: String + public let blobUrl: String + public let container: String + public let blobName: String +} + +/// Generic blob storage client for all ByteLyst iOS apps. +/// Handles SAS token acquisition + direct Azure Blob upload. +public final class BLBlobClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + } + + // MARK: - Upload + + /// Upload data to Azure Blob Storage. + /// 1. Acquires SAS token from platform-service + /// 2. Uploads directly to Azure Blob using the SAS URL + /// Returns the permanent blob URL on success. + public func upload( + data: Data, + container: String, + fileName: String, + contentType: String + ) async throws -> String { + // Step 1: Get SAS token + let sas = try await getSASToken(container: container, blobName: fileName, permissions: "w") + + // Step 2: Upload to blob storage using SAS URL + guard let url = URL(string: sas.sasUrl) else { + throw BLNetworkError.invalidURL(sas.sasUrl) + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") + request.httpBody = data + request.timeoutInterval = 120 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "Blob upload failed") + } + + return sas.blobUrl + } + + /// Convenience: upload audio data. + public func uploadAudio(data: Data, fileName: String) async throws -> String { + try await upload(data: data, container: "audio", fileName: fileName, contentType: "audio/wav") + } + + /// Convenience: upload an attachment (image, document, etc.). + public func uploadAttachment(data: Data, fileName: String, contentType: String) async throws -> String { + try await upload(data: data, container: "attachments", fileName: fileName, contentType: contentType) + } + + // MARK: - SAS Token + + /// Get a SAS token from platform-service. + public func getSASToken(container: String, blobName: String, permissions: String = "r") async throws -> BLSASResponse { + let body = [ + "container": container, + "blobName": blobName, + "permissions": permissions, + ] + return try await client.request(path: "/api/blob/sas", method: "POST", body: body, responseType: BLSASResponse.self) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift new file mode 100644 index 0000000..888e9d7 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift @@ -0,0 +1,153 @@ +// ── Broadcast Client ───────────────────────────────────── +// In-app broadcast message client for iOS/watchOS/macOS. +// Part of ByteLystPlatformSDK. + +import Foundation + +/// In-app message priority levels. +public enum BLBroadcastPriority: String, Codable, Sendable { + case low = "low" + case normal = "normal" + case high = "high" + case urgent = "urgent" +} + +/// In-app message display styles. +public enum BLBroadcastStyle: String, Codable, Sendable { + case banner = "banner" + case modal = "modal" + case toast = "toast" + case fullscreen = "fullscreen" +} + +/// In-app message status. +public enum BLBroadcastStatus: String, Codable, Sendable { + case unread = "unread" + case read = "read" + case dismissed = "dismissed" +} + +/// Represents an in-app broadcast message. +public struct BLInAppMessage: Codable, Sendable, Identifiable { + public let id: String + public let userId: String + public let productId: String + public let broadcastId: String + public let title: String + public let body: String + public let bodyMarkdown: String? + public let ctaText: String? + public let ctaUrl: String? + public let priority: BLBroadcastPriority + public let style: BLBroadcastStyle + public let dismissible: Bool + public let expiresAt: String? + public let status: BLBroadcastStatus + public let createdAt: String + public let updatedAt: String +} + +/// Broadcast client for fetching and managing in-app messages. +@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) +public class BLBroadcastClient: ObservableObject { + private let platformClient: BLPlatformClient + private var pollTask: Task? + + public init(platformClient: BLPlatformClient) { + self.platformClient = platformClient + } + + /// List active in-app messages for the current user. + public func listMessages() async throws -> [BLInAppMessage] { + let request = try platformClient.buildRequest(path: "/broadcasts") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") + } + + let result = try JSONDecoder().decode(MessagesResponse.self, from: data) + return result.messages + } + + /// Mark a message as read. + public func markRead(messageId: String) async throws { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/read", + method: "POST" + ) + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to mark message as read") + } + } + + /// Mark a message as dismissed. + public func markDismissed(messageId: String) async throws { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/dismiss", + method: "POST" + ) + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to dismiss message") + } + } + + /// Track a CTA click and get the redirect URL. + public func trackClick(messageId: String) async throws -> String? { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/click", + method: "POST" + ) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to track click") + } + + let result = try JSONDecoder().decode(ClickResponse.self, from: data) + return result.redirectUrl + } + + /// Start polling for new messages. + public func startPolling(interval: TimeInterval = 60, onUpdate: @escaping ([BLInAppMessage]) -> Void) { + stopPolling() + + pollTask = Task { + while !Task.isCancelled { + do { + let messages = try await listMessages() + onUpdate(messages) + } catch { + // Silently ignore polling errors + } + + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Stop polling for messages. + public func stopPolling() { + pollTask?.cancel() + pollTask = nil + } +} + +// MARK: - Response Types + +private struct MessagesResponse: Codable { + let messages: [BLInAppMessage] +} + +private struct ClickResponse: Codable { + let success: Bool + let redirectUrl: String? +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift new file mode 100644 index 0000000..4151fb0 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift @@ -0,0 +1,135 @@ +// ── Crash Reporter ────────────────────────────────────────── +// Generic MetricKit-based crash and performance reporting. +// Stores crash diagnostics locally for debugging and feedback forms. +// Product apps configure with a product-specific persistence key. +// Not available on watchOS — MetricKit is iOS/macOS only. + +import Foundation + +/// Crash report model stored locally. +/// Available on all platforms (data-only struct). +public struct BLCrashReport: Codable, Identifiable, Sendable { + public let id: String + public let date: Date + public let exceptionType: String? + public let signal: String? + public let terminationReason: String? + public let callStackData: Data? + + public init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { + self.id = UUID().uuidString + self.date = date + self.exceptionType = exceptionType + self.signal = signal + self.terminationReason = terminationReason + self.callStackData = callStackTree + } +} + +#if canImport(MetricKit) +import MetricKit + +/// Generic MetricKit crash reporter for all ByteLyst iOS/macOS apps. +/// Subscribes to MetricKit, stores crash reports in UserDefaults. +/// Not available on watchOS (MetricKit is iOS 13+ / macOS 12+ only). +@MainActor +public final class BLCrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { + + private let persistenceKey: String + private let maxReports: Int + + @Published public var lastCrashReport: Date? + @Published public var diagnosticCount: Int = 0 + + public init(productId: String, maxReports: Int = 50) { + self.persistenceKey = "\(productId)-crash-reports" + self.maxReports = maxReports + super.init() + MXMetricManager.shared.add(self) + loadStats() + } + + deinit { + MXMetricManager.shared.remove(self) + } + + // MARK: - MXMetricManagerSubscriber + + nonisolated public func didReceive(_ payloads: [MXMetricPayload]) { + // MetricKit delivers daily aggregated metrics — no action needed by default + } + + nonisolated public func didReceive(_ payloads: [MXDiagnosticPayload]) { + Task { @MainActor [weak self] in + guard let self else { return } + for payload in payloads { + self.processDiagnosticPayload(payload) + } + self.diagnosticCount += payloads.count + self.lastCrashReport = Date() + } + } + + // MARK: - Public API + + /// Get all stored crash reports. + public func loadCrashReports() -> [BLCrashReport] { + guard let data = UserDefaults.standard.data(forKey: persistenceKey), + let reports = try? JSONDecoder().decode([BLCrashReport].self, from: data) else { + return [] + } + return reports + } + + /// Clear all stored crash reports. + public func clearReports() { + UserDefaults.standard.removeObject(forKey: persistenceKey) + diagnosticCount = 0 + } + + // MARK: - Private + + private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { + if let crashDiagnostics = payload.crashDiagnostics { + for crash in crashDiagnostics { + let report = BLCrashReport( + date: Date(), + exceptionType: crash.exceptionType?.description, + signal: crash.signal?.description, + terminationReason: crash.terminationReason?.description, + callStackTree: crash.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + + if let hangDiagnostics = payload.hangDiagnostics { + for hang in hangDiagnostics { + let report = BLCrashReport( + date: Date(), + exceptionType: nil, + signal: nil, + terminationReason: "Hang: \(hang.hangDuration.description)", + callStackTree: hang.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + } + + private func storeCrashReport(_ report: BLCrashReport) { + var reports = loadCrashReports() + reports.append(report) + if reports.count > maxReports { + reports = Array(reports.suffix(maxReports)) + } + if let data = try? JSONEncoder().encode(reports) { + UserDefaults.standard.set(data, forKey: persistenceKey) + } + } + + private func loadStats() { + diagnosticCount = loadCrashReports().count + } +} +#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift new file mode 100644 index 0000000..96468bf --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift @@ -0,0 +1,182 @@ +import Foundation +import os + +/** + * Deep Link Router — Swift + * Handles routing from push notification deep links to app screens + */ + +public struct BLDeepLinkRoute { + public let screen: String + public let params: [String: String] + + public init(screen: String, params: [String: String] = [:]) { + self.screen = screen + self.params = params + } +} + +public typealias BLDeepLinkHandler = (BLDeepLinkRoute) -> Void + +/** + * Deep Link Router class + */ +@available(iOS 15.0, *) +public class BLDeepLinkRouter { + private var handlers: [String: BLDeepLinkHandler] = [:] + private var fallbackHandler: BLDeepLinkHandler? + + public init() {} + + /** + * Register a handler for a specific screen + */ + public func register(screen: String, handler: @escaping BLDeepLinkHandler) { + handlers[screen] = handler + } + + /** + * Set a fallback handler for unregistered screens + */ + public func setFallback(handler: @escaping BLDeepLinkHandler) { + fallbackHandler = handler + } + + /** + * Parse a deep link URL and extract route + */ + public func parseDeepLink(_ urlString: String) -> BLDeepLinkRoute? { + guard let url = URL(string: urlString) else { + return nil + } + + // Handle app-specific URLs: myapp://screen/params + if url.scheme != "http" && url.scheme != "https" { + let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + let screen = pathComponents.first ?? "home" + + var params: [String: String] = [:] + if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + for item in queryItems { + if let value = item.value { + params[item.name] = value + } + } + } + + return BLDeepLinkRoute(screen: screen, params: params) + } + + // Handle web URLs with deep link params + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let dlParam = components.queryItems?.first(where: { $0.name == "dl" })?.value { + return parseDeepLink(dlParam) + } + + // Handle path-based routing: /screen/params + let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + if !pathComponents.isEmpty { + let screen = pathComponents[0] + + var params: [String: String] = [:] + if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + for item in queryItems { + if let value = item.value { + params[item.name] = value + } + } + } + + return BLDeepLinkRoute(screen: screen, params: params) + } + + return nil + } + + /** + * Handle a deep link route + */ + @discardableResult + public func handle(_ route: BLDeepLinkRoute) -> Bool { + if let handler = handlers[route.screen] { + handler(route) + return true + } + + if let fallback = fallbackHandler { + fallback(route) + return true + } + + Logger.deepLink.warning("No handler for screen: \(route.screen)") + return false + } + + /** + * Process a deep link URL end-to-end + */ + @discardableResult + public func process(_ urlString: String) -> Bool { + guard let route = parseDeepLink(urlString) else { + Logger.deepLink.warning("Failed to parse deep link: \(urlString)") + return false + } + return handle(route) + } +} + +/** + * Create a broadcast deep link URL + */ +public func createBroadcastDeepLink( + baseURL: String, + screen: String, + params: [String: String] = [:], + broadcastId: String? = nil +) -> String? { + guard var components = URLComponents(string: baseURL) else { + return nil + } + + components.path = "/\(screen)" + + var queryItems: [URLQueryItem] = params.map { URLQueryItem(name: $0.key, value: $0.value) } + + if let broadcastId = broadcastId { + queryItems.append(URLQueryItem(name: "broadcastId", value: broadcastId)) + } + + if !queryItems.isEmpty { + components.queryItems = queryItems + } + + return components.string +} + +/** + * Common deep link screens + */ +public struct BLDeepLinkScreens { + // Broadcasts + public static let broadcast = "broadcast" + public static let announcements = "announcements" + + // Surveys + public static let survey = "survey" + public static let surveyList = "surveys" + + // Product-specific + public static let settings = "settings" + public static let profile = "profile" + public static let upgrade = "upgrade" + public static let support = "support" + + // Fallback + public static let home = "home" +} + +// Logger extension +@available(iOS 15.0, *) +extension Logger { + static let deepLink = Logger(subsystem: "com.bytelyst.platform", category: "DeepLink") +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift new file mode 100644 index 0000000..7c89565 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift @@ -0,0 +1,86 @@ +// ── Feature Flag Client ────────────────────────────────────── +// Generic feature flag polling from platform-service /api/flags/poll. +// Flags cached in memory, re-polled at configurable interval. +// Matches the platform-service flags module API. + +import Foundation + +/// Generic feature flag client for all ByteLyst iOS apps. +/// Polls platform-service and caches flag values in memory. +public final class BLFeatureFlagClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let pollIntervalSec: TimeInterval + + private var flags: [String: Bool] = [:] + private let flagsLock = NSLock() + private var pollTimer: Timer? + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + pollIntervalSec: TimeInterval = 5 * 60 + ) { + self.config = config + self.client = client + self.pollIntervalSec = pollIntervalSec + } + + // MARK: - Lifecycle + + /// Start polling for feature flags. + public func start(userId: String? = nil) { + Task { await fetchFlags(userId: userId) } + pollTimer?.invalidate() + pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in + guard let self else { return } + Task { await self.fetchFlags(userId: userId) } + } + } + + /// Stop polling. + public func stop() { + pollTimer?.invalidate() + pollTimer = nil + } + + // MARK: - Query + + /// Check if a feature flag is enabled. + public func isEnabled(_ key: String) -> Bool { + flagsLock.lock() + defer { flagsLock.unlock() } + return flags[key] == true + } + + /// Get all current flag values. + public func allFlags() -> [String: Bool] { + flagsLock.lock() + defer { flagsLock.unlock() } + return flags + } + + // MARK: - Fetch + + private struct FlagsResponse: Codable { + let flags: [String: Bool] + } + + private func fetchFlags(userId: String? = nil) async { + var path = "/api/flags/poll?platform=\(config.platform)" + if let userId { path += "&userId=\(userId)" } + + do { + let result = try await client.request( + path: path, + responseType: FlagsResponse.self + ) + flagsLock.lock() + flags = result.flags + flagsLock.unlock() + } catch { + // Keep existing flags on error — silent failure + } + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift new file mode 100644 index 0000000..efd6e16 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift @@ -0,0 +1,269 @@ +// ── Feedback Client ───────────────────────────────────── +// Submit user feedback with optional screenshot attachments. +// Part of ByteLystPlatformSDK for all iOS/watchOS/macOS apps. +// +// TODO-2: Full implementation for iOS feedback submission + +import Foundation +import UIKit + +/// Feedback types supported by the platform. +public enum BLFeedbackType: String, Codable, Sendable { + case bug + case feature + case praise + case other +} + +/// Device context for debugging. +public struct BLDeviceContext: Codable, Sendable { + public let osVersion: String + public let appVersion: String + public let deviceModel: String + public let screenResolution: String + public let locale: String + + public init( + osVersion: String, + appVersion: String, + deviceModel: String, + screenResolution: String, + locale: String + ) { + self.osVersion = osVersion + self.appVersion = appVersion + self.deviceModel = deviceModel + self.screenResolution = screenResolution + self.locale = locale + } + + /// Auto-detect current device context. + public static func current() -> BLDeviceContext { + let device = UIDevice.current + let screen = UIScreen.main + let bounds = screen.bounds + let scale = screen.scale + + return BLDeviceContext( + osVersion: device.systemVersion, + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", + deviceModel: device.model, + screenResolution: "\(Int(bounds.width * scale))x\(Int(bounds.height * scale))", + locale: Locale.current.identifier + ) + } +} + +/// Screenshot content types. +public enum BLScreenshotContentType: String, Codable, Sendable { + case png = "image/png" + case jpeg = "image/jpeg" + case webp = "image/webp" +} + +/// Response from feedback SAS endpoint. +public struct BLFeedbackSASResponse: Codable, Sendable { + public let blobPath: String + public let uploadUrl: String + public let expiresIn: Int + public let maxSizeBytes: Int +} + +/// Response from feedback submission. +public struct BLFeedbackResponse: Codable, Sendable { + public let id: String + public let productId: String + public let userId: String + public let type: String + public let title: String + public let status: String + public let createdAt: String + public let screenshotBlobPath: String? +} + +/// Parameters for submitting feedback. +public struct BLFeedbackParams: Sendable { + public let type: BLFeedbackType + public let title: String + public let body: String? + public let screen: String? + public let rating: Int? + public let screenshot: (data: Data, contentType: BLScreenshotContentType)? + public let deviceContext: BLDeviceContext? + + public init( + type: BLFeedbackType, + title: String, + body: String? = nil, + screen: String? = nil, + rating: Int? = nil, + screenshot: (data: Data, contentType: BLScreenshotContentType)? = nil, + deviceContext: BLDeviceContext? = nil + ) { + self.type = type + self.title = title + self.body = body + self.screen = screen + self.rating = rating + self.screenshot = screenshot + self.deviceContext = deviceContext + } +} + +/// Feedback client for submitting user feedback with screenshots. +/// TODO-2: Full implementation +public final class BLFeedbackClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let blobClient: BLBlobClient + + public init(config: BLPlatformConfig, client: BLPlatformClient, blobClient: BLBlobClient) { + self.config = config + self.client = client + self.blobClient = blobClient + } + + // MARK: - Submit Feedback + + /// Submit feedback with optional screenshot. + /// + /// Flow: + /// 1. If screenshot provided, upload to blob storage + /// 2. Submit feedback with screenshot metadata + /// + /// TODO-2: Full implementation + public func submitFeedback(_ params: BLFeedbackParams) async throws -> BLFeedbackResponse { + var screenshotMeta: (blobPath: String, contentType: String, sizeBytes: Int)? + + // Step 1: Handle screenshot upload if provided + if let screenshot = params.screenshot { + // Get SAS URL for upload + let sas = try await generateSASURL(contentType: screenshot.contentType) + + // Upload screenshot + try await uploadScreenshot(data: screenshot.data, sasURL: sas.uploadUrl, contentType: screenshot.contentType.rawValue) + + screenshotMeta = ( + blobPath: sas.blobPath, + contentType: screenshot.contentType.rawValue, + sizeBytes: screenshot.data.count + ) + } + + // Step 2: Submit feedback + var body: [String: Any] = [ + "type": params.type.rawValue, + "title": params.title, + ] + + if let bodyText = params.body { body["body"] = bodyText } + if let screen = params.screen { body["screen"] = screen } + if let rating = params.rating { body["rating"] = rating } + if let meta = screenshotMeta { + body["screenshotBlobPath"] = meta.blobPath + body["screenshotContentType"] = meta.contentType + body["screenshotSizeBytes"] = meta.sizeBytes + } + if let context = params.deviceContext { + body["deviceContext"] = [ + "osVersion": context.osVersion, + "appVersion": context.appVersion, + "deviceModel": context.deviceModel, + "screenResolution": context.screenResolution, + "locale": context.locale, + ] + } + + throw BLFeedbackError.notImplemented( + "submitFeedback body encoding and API call not yet implemented. " + + "Use client.request(path: \"/api/feedback\", method: \"POST\", body: body)" + ) + } + + /// Capture screenshot and submit feedback in one operation. + /// + /// TODO-2: Implement using UIApplication.shared.windows or UIScreen + public func captureAndSubmit( + type: BLFeedbackType, + title: String, + body: String? = nil + ) async throws -> BLFeedbackResponse { + throw BLFeedbackError.notImplemented( + "captureAndSubmit not yet implemented.\n\n" + + "To implement:\n" + + "1. Use UIGraphicsImageRenderer or UIScreen to capture\n" + + "2. Convert UIImage to Data (PNG/JPEG)\n" + + "3. Call submitFeedback with captured data\n\n" + + "Example:\n" + + "let window = UIApplication.shared.windows.first\n" + + "UIGraphicsBeginImageContext(window.bounds.size)\n" + + "window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)\n" + + "let image = UIGraphicsGetImageFromCurrentImageContext()\n" + + "UIGraphicsEndImageContext()" + ) + } + + // MARK: - Screenshot Capture + + /// Capture current screen as UIImage. + /// + /// TODO-2: Full implementation + public func captureScreen() async throws -> UIImage { + throw BLFeedbackError.notImplemented( + "captureScreen not yet implemented. Use UIScreen or UIApplication.shared.windows" + ) + } + + /// Capture specific UIView as UIImage. + /// + /// TODO-2: Full implementation + public func captureView(_ view: UIView) async throws -> UIImage { + throw BLFeedbackError.notImplemented( + "captureView not yet implemented. Use UIGraphicsImageRenderer or drawHierarchy" + ) + } + + // MARK: - Private + + private func generateSASURL(contentType: BLScreenshotContentType) async throws -> BLFeedbackSASResponse { + let body = ["contentType": contentType.rawValue] + return try await client.request( + path: "/api/feedback/sas", + method: "POST", + body: body, + responseType: BLFeedbackSASResponse.self + ) + } + + private func uploadScreenshot(data: Data, sasURL: String, contentType: String) async throws { + guard let url = URL(string: sasURL) else { + throw BLNetworkError.invalidURL(sasURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") + request.httpBody = data + request.timeoutInterval = 60 + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw BLNetworkError.httpError( + statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, + message: "Screenshot upload failed" + ) + } + } +} + +/// Errors specific to feedback operations. +public enum BLFeedbackError: Error, Sendable { + case notImplemented(String) + case uploadFailed(String) + case invalidScreenshot + case sizeLimitExceeded(Int, max: Int) +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift new file mode 100644 index 0000000..0260d61 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift @@ -0,0 +1,277 @@ +// ── Field Encryption ──────────────────────────────────────── +// AES-256-GCM field-level encryption compatible with @bytelyst/field-encrypt (TypeScript). +// Produces and consumes the same EncryptedField JSON structure across all platforms. +// Uses Apple CryptoKit — available on iOS 13+, macOS 10.15+, watchOS 6+. + +import CryptoKit +import Foundation + +// MARK: - EncryptedField Model + +/// Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). +/// All byte arrays are hex-encoded strings for JSON serialization. +public struct BLEncryptedField: Codable, Sendable, Equatable { + /// Sentinel — always `true` for encrypted fields. + public let __encrypted: Bool + /// Schema version (currently 1). + public let v: Int + /// Algorithm identifier. + public let alg: String + /// Ciphertext (hex-encoded). + public let ct: String + /// Initialization vector (hex-encoded, 12 bytes = 24 hex chars). + public let iv: String + /// GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). + public let tag: String + /// DEK identifier — identifies which key was used for encryption. + public let dekId: String + + public init(ct: String, iv: String, tag: String, dekId: String) { + self.__encrypted = true + self.v = 1 + self.alg = "aes-256-gcm" + self.ct = ct + self.iv = iv + self.tag = tag + self.dekId = dekId + } +} + +// MARK: - BLFieldEncrypt + +/// AES-256-GCM field-level encryption. +/// +/// Produces `BLEncryptedField` objects that are wire-compatible with the +/// TypeScript `@bytelyst/field-encrypt` package. Backends and native clients +/// can encrypt/decrypt the same fields interchangeably. +/// +/// Usage: +/// ```swift +/// let key = BLFieldEncrypt.generateKey() +/// let encrypted = try BLFieldEncrypt.encrypt("sensitive data", key: key, dekId: "dek_user1_notes") +/// let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) +/// ``` +public enum BLFieldEncrypt { + + /// AES-256-GCM key size in bytes. + public static let keySize = 32 + + /// GCM nonce (IV) size in bytes. + private static let nonceSize = 12 + + // MARK: - Encrypt + + /// Encrypt a plaintext string with AES-256-GCM. + /// + /// - Parameters: + /// - plaintext: UTF-8 string to encrypt. + /// - key: 32-byte symmetric key. + /// - dekId: DEK identifier stored in the output for key lookup on decrypt. + /// - aad: Optional additional authenticated data (e.g., "userId:context"). + /// - Returns: `BLEncryptedField` with hex-encoded ciphertext, IV, and tag. + /// - Throws: `BLFieldEncryptError` if key size is wrong or encryption fails. + public static func encrypt( + _ plaintext: String, + key: SymmetricKey, + dekId: String, + aad: String? = nil + ) throws -> BLEncryptedField { + guard key.bitCount == keySize * 8 else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) + } + + let plaintextData = Data(plaintext.utf8) + let nonce = AES.GCM.Nonce() + + let sealedBox: AES.GCM.SealedBox + if let aad = aad { + sealedBox = try AES.GCM.seal( + plaintextData, + using: key, + nonce: nonce, + authenticating: Data(aad.utf8) + ) + } else { + sealedBox = try AES.GCM.seal(plaintextData, using: key, nonce: nonce) + } + + return BLEncryptedField( + ct: sealedBox.ciphertext.hexString, + iv: Data(sealedBox.nonce).hexString, + tag: sealedBox.tag.hexString, + dekId: dekId + ) + } + + // MARK: - Decrypt + + /// Decrypt a `BLEncryptedField` back to plaintext. + /// + /// - Parameters: + /// - field: Encrypted field object (from Cosmos DB or API response). + /// - key: 32-byte symmetric key (must match the key used to encrypt). + /// - aad: Optional AAD (must match the AAD used during encryption). + /// - Returns: Decrypted UTF-8 string. + /// - Throws: `BLFieldEncryptError` if decryption or authentication fails. + public static func decrypt( + _ field: BLEncryptedField, + key: SymmetricKey, + aad: String? = nil + ) throws -> String { + guard key.bitCount == keySize * 8 else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) + } + + guard let ctData = Data(hexString: field.ct), + let ivData = Data(hexString: field.iv), + let tagData = Data(hexString: field.tag) else { + throw BLFieldEncryptError.invalidHexEncoding + } + + let nonce = try AES.GCM.Nonce(data: ivData) + let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctData, tag: tagData) + + let decryptedData: Data + if let aad = aad { + decryptedData = try AES.GCM.open(sealedBox, using: key, authenticating: Data(aad.utf8)) + } else { + decryptedData = try AES.GCM.open(sealedBox, using: key) + } + + guard let plaintext = String(data: decryptedData, encoding: .utf8) else { + throw BLFieldEncryptError.utf8DecodingFailed + } + + return plaintext + } + + // MARK: - Key Generation + + /// Generate a random 32-byte AES-256 symmetric key. + public static func generateKey() -> SymmetricKey { + SymmetricKey(size: .bits256) + } + + /// Create a `SymmetricKey` from a hex-encoded string (64 hex chars = 32 bytes). + public static func keyFromHex(_ hex: String) throws -> SymmetricKey { + guard let data = Data(hexString: hex), data.count == keySize else { + throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: hex.count / 2) + } + return SymmetricKey(data: data) + } + + // MARK: - Keychain Key Derivation + + /// Get or create a persistent encryption key in the Keychain. + /// + /// On first call, generates a random 32-byte AES-256 key and stores it + /// as a hex string in the Keychain. On subsequent calls, loads the + /// existing key. This provides a stable per-device DEK for client-side + /// encryption without requiring the backend to provision keys. + /// + /// - Parameters: + /// - service: Keychain service identifier (typically the app's bundle ID). + /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). + /// - Returns: A 32-byte `SymmetricKey` backed by Keychain storage. + /// - Throws: `BLFieldEncryptError.invalidHexEncoding` if stored key is corrupt. + public static func getOrCreateKey(service: String, account: String = "field_encrypt_dek") throws -> SymmetricKey { + if let existingHex = BLKeychain.read(service: service, key: account) { + return try keyFromHex(existingHex) + } + + let newKey = generateKey() + let hex = newKey.withUnsafeBytes { Data($0).hexString } + BLKeychain.save(service: service, key: account, value: hex) + return newKey + } + + /// Load an existing encryption key from the Keychain without creating one. + /// + /// - Parameters: + /// - service: Keychain service identifier. + /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). + /// - Returns: The stored `SymmetricKey`, or `nil` if none exists. + public static func loadKey(service: String, account: String = "field_encrypt_dek") -> SymmetricKey? { + guard let hex = BLKeychain.read(service: service, key: account) else { return nil } + return try? keyFromHex(hex) + } + + /// Delete the stored encryption key from the Keychain. + /// + /// - Parameters: + /// - service: Keychain service identifier. + /// - account: Keychain account key. + /// - Returns: `true` if the key was deleted or didn't exist. + @discardableResult + public static func deleteKey(service: String, account: String = "field_encrypt_dek") -> Bool { + BLKeychain.delete(service: service, key: account) + } + + // MARK: - Type Guard + + /// Check if a JSON value represents an encrypted field. + /// Compatible with the TypeScript `isEncryptedField()` type guard. + public static func isEncrypted(_ value: Any?) -> Bool { + if let dict = value as? [String: Any], + let sentinel = dict["__encrypted"] as? Bool, + sentinel == true, + dict["v"] != nil, + dict["alg"] != nil, + dict["ct"] != nil, + dict["iv"] != nil, + dict["tag"] != nil, + dict["dekId"] != nil { + return true + } + return false + } + + /// Check if a `Codable` value is a `BLEncryptedField`. + public static func isEncrypted(_ field: BLEncryptedField?) -> Bool { + field?.__encrypted == true + } +} + +// MARK: - Errors + +public enum BLFieldEncryptError: LocalizedError { + case invalidKeySize(expected: Int, actual: Int) + case invalidHexEncoding + case utf8DecodingFailed + + public var errorDescription: String? { + switch self { + case .invalidKeySize(let expected, let actual): + return "AES-256-GCM requires a \(expected)-byte key, got \(actual)" + case .invalidHexEncoding: + return "Failed to decode hex-encoded field data" + case .utf8DecodingFailed: + return "Decrypted data is not valid UTF-8" + } + } +} + +// MARK: - Hex Helpers + +extension Data { + /// Hex-encode data to a lowercase string. + var hexString: String { + map { String(format: "%02x", $0) }.joined() + } + + /// Initialize Data from a hex-encoded string. + init?(hexString: String) { + let hex = hexString.lowercased() + guard hex.count.isMultiple(of: 2) else { return nil } + + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let nextIndex = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. Void + let onTap: () async -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(message.title) + .font(.headline) + + if !message.body.isEmpty { + Text(message.body) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + } + + if message.ctaText != nil { + Text("Tap to open") + .font(.caption) + .foregroundColor(.blue) + } + } + + Spacer() + + if message.dismissible { + Button(action: { Task { await onDismiss() } }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + .padding(8) + } + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .shadow(radius: 2) + ) + .contentShape(Rectangle()) + .onTapGesture { + Task { await onTap() } + } + } + + private var backgroundColor: Color { + switch message.priority { + case .urgent: + return Color.red.opacity(0.1) + case .high: + return Color.orange.opacity(0.1) + default: + return Color(.systemBackground) + } + } +} + +@available(iOS 15.0, *) +public struct BLBroadcastModal: View { + @ObservedObject var client: BLBroadcastClient + @State private var currentMessage: BLInAppMessage? + @State private var isPresented = false + + public init(client: BLBroadcastClient) { + self.client = client + } + + public var body: some View { + EmptyView() + .sheet(isPresented: $isPresented) { + if let message = currentMessage { + ModalContent( + message: message, + onDismiss: { await dismissMessage() }, + onAction: { await handleAction() } + ) + } + } + .task { + startPolling() + } + } + + private func startPolling() { + client.startPolling(interval: 30) { messages in + let modalMessages = messages.filter { + $0.status == .unread && ($0.style == .modal || $0.style == .fullscreen) + } + if let first = modalMessages.first, self.currentMessage == nil { + self.currentMessage = first + self.isPresented = true + } + } + } + + private func dismissMessage() async { + if let message = currentMessage { + try? await client.markDismissed(messageId: message.id) + } + isPresented = false + currentMessage = nil + } + + private func handleAction() async { + if let message = currentMessage { + _ = try? await client.trackClick(messageId: message.id) + if let urlString = message.ctaUrl, let url = URL(string: urlString) { + await UIApplication.shared.open(url) + } + try? await client.markRead(messageId: message.id) + } + isPresented = false + currentMessage = nil + } +} + +@available(iOS 15.0, *) +struct ModalContent: View { + let message: BLInAppMessage + let onDismiss: () async -> Void + let onAction: () async -> Void + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 20) { + Text(message.title) + .font(.title2.bold()) + + if !message.body.isEmpty { + Text(message.body) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + + if message.ctaText != nil { + Button(action: { Task { await onAction() } }) { + Text(message.ctaText!) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(12) + } + } + } + .padding() + } + .navigationBarItems( + trailing: message.dismissible ? Button("Close") { + Task { await onDismiss() } + } : nil + ) + } + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift new file mode 100644 index 0000000..eec56c9 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift @@ -0,0 +1,54 @@ +// ── Keychain Helper ─────────────────────────────────────────── +// Generic Keychain CRUD for storing auth tokens securely. +// Service identifier is configurable per product via BLPlatformConfig.bundleId. + +import Foundation +import Security + +public enum BLKeychain { + + /// Save a string value to the Keychain. + @discardableResult + public static func save(service: String, key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + delete(service: service, key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + return SecItemAdd(query as CFDictionary, nil) == errSecSuccess + } + + /// Read a string value from the Keychain. + public static func read(service: String, key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Delete a value from the Keychain. + @discardableResult + public static func delete(service: String, key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift new file mode 100644 index 0000000..736965d --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift @@ -0,0 +1,60 @@ +// ── Kill Switch Client ────────────────────────────────────── +// Checks platform-service kill switch at app launch. +// If the app is disabled server-side, surfaces a maintenance message. +// Fails open — network errors allow the app to run. + +import Foundation + +/// Generic kill switch client for all ByteLyst iOS apps. +/// Checks GET /api/settings/kill-switch?productId=X at launch. +public final class BLKillSwitchClient { + + private let config: BLPlatformConfig + + /// Whether the app is disabled by the server. + public private(set) var isDisabled = false + + /// Maintenance message from the server (empty if not disabled). + public private(set) var maintenanceMessage = "" + + public init(config: BLPlatformConfig) { + self.config = config + } + + /// Check kill switch status. Non-blocking — defaults to enabled on failure (fail open). + public func check() async { + guard let url = URL(string: "\(config.baseURL)/api/settings/kill-switch?productId=\(config.productId)") else { return } + + var request = URLRequest(url: url) + request.timeoutInterval = 5 + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } + + struct KillSwitchResponse: Codable { + let enabled: Bool? + let disabled: Bool? + let message: String? + } + + let result = try JSONDecoder().decode(KillSwitchResponse.self, from: data) + + // Support both `enabled: false` and `disabled: true` patterns + if result.disabled == true || result.enabled == false { + isDisabled = true + maintenanceMessage = result.message ?? "\(config.productId) is temporarily unavailable for maintenance." + } + } catch { + // Network error — allow the app to run (fail open) + } + } + + /// Reset the kill switch state (e.g. for retry). + public func reset() { + isDisabled = false + maintenanceMessage = "" + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift new file mode 100644 index 0000000..f6926f1 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift @@ -0,0 +1,104 @@ +// ── License Client ────────────────────────────────────────── +// Generic license key activation via platform-service. +// Flow: enter key → POST /api/licenses/activate → receive tokens. +// Product apps configure with BLPlatformConfig. + +import Foundation + +/// License information returned from platform-service. +public struct BLLicenseInfo: Codable, Sendable { + public let key: String + public let plan: String + public let status: String + public let devicesUsed: Int + public let maxDevices: Int + public let expiresAt: String? + + public init(key: String, plan: String, status: String, devicesUsed: Int, maxDevices: Int, expiresAt: String?) { + self.key = key + self.plan = plan + self.status = status + self.devicesUsed = devicesUsed + self.maxDevices = maxDevices + self.expiresAt = expiresAt + } +} + +/// Activation result containing tokens + license info. +public struct BLActivationResult: Sendable { + public let accessToken: String + public let refreshToken: String + public let license: BLLicenseInfo +} + +/// Generic license client for all ByteLyst iOS apps. +/// Handles license key activation and status checking via platform-service. +public final class BLLicenseClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + public init(config: BLPlatformConfig, client: BLPlatformClient) { + self.config = config + self.client = client + } + + // MARK: - Activate + + /// Activate a license key on this device. + /// Returns activation result with tokens and license info. + public func activate(key: String, deviceId: String, deviceName: String) async throws -> BLActivationResult { + let body: [String: String] = [ + "key": key.uppercased().trimmingCharacters(in: .whitespaces), + "deviceId": deviceId, + "deviceName": deviceName, + "platform": config.platform, + ] + + let (data, _) = try await client.rawRequest(path: "/api/licenses/activate", method: "POST", body: body) + + struct ActivateResponse: Codable { + let accessToken: String + let refreshToken: String + let license: LicenseDoc + } + struct LicenseDoc: Codable { + let key: String + let plan: String + let status: String + let deviceIds: [String] + let maxDevices: Int + let expiresAt: String? + let userId: String + } + + let result = try JSONDecoder().decode(ActivateResponse.self, from: data) + + let info = BLLicenseInfo( + key: result.license.key, + plan: result.license.plan, + status: result.license.status, + devicesUsed: result.license.deviceIds.count, + maxDevices: result.license.maxDevices, + expiresAt: result.license.expiresAt + ) + + return BLActivationResult( + accessToken: result.accessToken, + refreshToken: result.refreshToken, + license: info + ) + } + + // MARK: - Status + + /// Check license status without activating. + public func checkStatus(key: String) async throws -> BLLicenseInfo { + let trimmedKey = key.uppercased().trimmingCharacters(in: .whitespaces) + let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? trimmedKey + return try await client.request( + path: "/api/licenses/status/\(encodedKey)", + responseType: BLLicenseInfo.self + ) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift new file mode 100644 index 0000000..8017f83 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift @@ -0,0 +1,234 @@ +// ── Platform HTTP Client ───────────────────────────────────── +// Generic URLSession wrapper with auth header injection, x-request-id, +// timeout, and product ID header. Used by all BL* services. + +import Foundation + +public final class BLPlatformClient: @unchecked Sendable { + + public let config: BLPlatformConfig + private let session: URLSession + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Auth token injected by BLAuthClient after login/refresh. + private var _authToken: String? + private let tokenLock = NSLock() + + public var authToken: String? { + get { tokenLock.lock(); defer { tokenLock.unlock() }; return _authToken } + set { tokenLock.lock(); defer { tokenLock.unlock() }; _authToken = newValue } + } + + public init(config: BLPlatformConfig, timeoutSeconds: TimeInterval = 15) { + self.config = config + + let urlConfig = URLSessionConfiguration.default + urlConfig.timeoutIntervalForRequest = timeoutSeconds + urlConfig.waitsForConnectivity = true + session = URLSession(configuration: urlConfig) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + /// Test-only initializer accepting a custom URLSessionConfiguration + /// so callers can inject MockURLProtocol for intercepting requests. + public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { + self.config = config + session = URLSession(configuration: sessionConfiguration) + + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + // MARK: - Public Request Methods + + /// Perform an authenticated request and decode the response. + public func request( + path: String, + method: String = "GET", + body: (any Encodable)? = nil, + responseType: T.Type + ) async throws -> T { + let (data, _) = try await rawRequest(path: path, method: method, body: body) + return try decoder.decode(T.self, from: data) + } + + /// Perform an authenticated request, returning raw (Data, HTTPURLResponse). + public func rawRequest( + path: String, + method: String = "GET", + body: (any Encodable)? = nil + ) async throws -> (Data, HTTPURLResponse) { + guard let url = URL(string: "\(config.baseURL)\(path)") else { + throw BLNetworkError.invalidURL(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body { + request.httpBody = try encoder.encode(body) + } + + let (data, response) = try await session.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw BLNetworkError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + // Try to extract server error message + let message: String? = { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = json["message"] as? String { return msg } + return nil + }() + throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) + } + + return (data, http) + } + + /// Perform an authenticated request with pre-encoded JSON body data. + /// Used by passkey methods that need to send raw JSON (e.g. from JSONSerialization). + public func rawRequest( + path: String, + method: String = "GET", + rawBody: Data? = nil + ) async throws -> (Data, HTTPURLResponse) { + guard let url = URL(string: "\(config.baseURL)\(path)") else { + throw BLNetworkError.invalidURL(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + request.httpBody = rawBody + + let (data, response) = try await session.data(for: request) + + guard let http = response as? HTTPURLResponse else { + throw BLNetworkError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + let message: String? = { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = json["message"] as? String { return msg } + return nil + }() + throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) + } + + return (data, http) + } + + /// Fire-and-forget POST (used by telemetry — errors silently ignored). + public func fireAndForget(path: String, body: Data) { + guard let url = URL(string: "\(config.baseURL)\(path)") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + request.timeoutInterval = 10 + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + session.dataTask(with: request) { _, _, _ in }.resume() + } + + // MARK: - Request Builder + + /// Build an authenticated URLRequest (used by BLBroadcastClient, BLSurveyClient). + public func buildRequest( + path: String, + method: String = "GET", + body: [String: Any]? = nil + ) throws -> URLRequest { + guard let url = URL(string: "\(config.baseURL)\(path)") else { + throw BLNetworkError.invalidURL(path) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") + request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") + + if let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + + return request + } + + // MARK: - Encoder/Decoder Access + + public var jsonEncoder: JSONEncoder { encoder } + public var jsonDecoder: JSONDecoder { decoder } +} + +// MARK: - Errors + +/// Legacy alias used by BLBroadcastClient, BLSurveyClient, BLDeepLinkRouter. +public enum BLPlatformError: LocalizedError { + case requestFailed(String) + + public var errorDescription: String? { + switch self { + case .requestFailed(let msg): return msg + } + } +} + +public enum BLNetworkError: LocalizedError { + case invalidURL(String) + case invalidResponse + case httpError(statusCode: Int, message: String?) + case notAuthenticated + + public var errorDescription: String? { + switch self { + case .invalidURL(let path): return "Invalid URL: \(path)" + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let msg): return msg ?? "Server error (\(code))" + case .notAuthenticated: return "Not signed in" + } + } + + public var statusCode: Int? { + if case .httpError(let code, _) = self { return code } + return nil + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift new file mode 100644 index 0000000..c0c7c61 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift @@ -0,0 +1,64 @@ +// ── Platform Configuration ─────────────────────────────────── +// Product-specific config that every BL* service reads from. +// Each app creates ONE config at launch and passes it to all services. + +import Foundation + +/// Configuration for all ByteLyst platform services. +/// Create one instance at app launch and inject into BLTelemetryClient, BLAuthClient, etc. +public struct BLPlatformConfig { + /// Product identifier (e.g. "chronomind", "lysnrai", "peakpulse", "nomgap", "mindlyst"). + public let productId: String + + /// Platform-service base URL (e.g. "https://api.chronomind.app" or "http://localhost:4003/api"). + public let baseURL: String + + /// Platform string sent in telemetry (e.g. "ios", "watchos", "macos"). + public let platform: String + + /// Channel string sent in telemetry (e.g. "native", "mobile_app"). + public let channel: String + + /// Bundle ID used as Keychain service identifier. + public let bundleId: String + + /// App Group ID for sharing data between app and extensions (optional). + public let appGroupId: String? + + public init( + productId: String, + baseURL: String, + platform: String = "ios", + channel: String = "native", + bundleId: String, + appGroupId: String? = nil + ) { + self.productId = productId + self.baseURL = baseURL + self.platform = platform + self.channel = channel + self.bundleId = bundleId + self.appGroupId = appGroupId + } + + /// Convenience: read PLATFORM_SERVICE_URL from Info.plist, fall back to provided default. + public static func fromInfoPlist( + productId: String, + defaultBaseURL: String, + platform: String = "ios", + channel: String = "native", + bundleId: String, + appGroupId: String? = nil + ) -> BLPlatformConfig { + let url = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String + ?? defaultBaseURL + return BLPlatformConfig( + productId: productId, + baseURL: url, + platform: platform, + channel: channel, + bundleId: bundleId, + appGroupId: appGroupId + ) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift new file mode 100644 index 0000000..dc31f23 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift @@ -0,0 +1,369 @@ +// ── Survey Client ──────────────────────────────────────── +// In-app survey client for iOS/watchOS/macOS. +// Part of ByteLystPlatformSDK. + +import Foundation + +/// Survey question types. +public enum BLSurveyQuestionType: String, Codable, Sendable { + case singleChoice = "single_choice" + case multipleChoice = "multiple_choice" + case rating = "rating" + case nps = "nps" + case textShort = "text_short" + case textLong = "text_long" + case dropdown = "dropdown" + case scale = "scale" + case ranking = "ranking" +} + +/// Survey status. +public enum BLSurveyStatus: String, Codable, Sendable { + case draft = "draft" + case active = "active" + case paused = "paused" + case closed = "closed" +} + +/// Represents a survey question option. +public struct BLSurveyOption: Codable, Sendable, Identifiable { + public let id: String + public let text: String + public let emoji: String? +} + +/// Represents a survey question. +public struct BLSurveyQuestion: Codable, Sendable, Identifiable { + public let id: String + public let type: BLSurveyQuestionType + public let text: String + public let description: String? + public let required: Bool + public let options: [BLSurveyOption]? + public let minLength: Int? + public let maxLength: Int? + public let minValue: Int? + public let maxValue: Int? +} + +/// Represents an active survey for display. +public struct BLActiveSurvey: Codable, Sendable, Identifiable { + public let id: String + public let title: String + public let description: String? + public let questions: [BLSurveyQuestion] + public let incentive: BLSurveyIncentive? + public let displayTrigger: BLSurveyTrigger +} + +/// Survey incentive. +public struct BLSurveyIncentive: Codable, Sendable { + public let type: String + public let amount: Int +} + +/// Survey display trigger. +public struct BLSurveyTrigger: Codable, Sendable { + public let type: String + public let seconds: Int? + public let eventName: String? + public let pagePattern: String? +} + +/// Survey answer types. +public enum BLSurveyAnswer: Codable, Sendable { + case singleChoice(optionId: String) + case multipleChoice(optionIds: [String]) + case rating(value: Int) + case nps(value: Int) + case text(value: String) + case ranking(rankedOptionIds: [String]) + + public var type: String { + switch self { + case .singleChoice: return "single_choice" + case .multipleChoice: return "multiple_choice" + case .rating: return "rating" + case .nps: return "nps" + case .text: return "text" + case .ranking: return "ranking" + } + } +} + +/// Represents a survey response. +public struct BLSurveyResponse: Codable, Sendable { + public let id: String + public let surveyId: String + public let userId: String + public var answers: [String: BLSurveyAnswer] + public var currentQuestionIndex: Int + public let startedAt: String + public var completedAt: String? + public var isComplete: Bool + public var incentiveClaimed: Bool + public var incentiveClaimedAt: String? + public let createdAt: String + public let updatedAt: String +} + +/// Survey client for managing in-app surveys. +@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) +public class BLSurveyClient: ObservableObject { + private let platformClient: BLPlatformClient + private var pollTask: Task? + private var cachedResponses: [String: BLSurveyResponse] = [:] + + public init(platformClient: BLPlatformClient) { + self.platformClient = platformClient + } + + /// Get active survey for the current user (if any). + public func getActiveSurvey() async throws -> BLActiveSurvey? { + let request = try platformClient.buildRequest(path: "/surveys/active") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") + } + + let result = try JSONDecoder().decode(ActiveSurveyResponse.self, from: data) + return result.survey + } + + /// Start a survey session. + public func startSurvey(surveyId: String) async throws -> BLSurveyResponse { + let request = try platformClient.buildRequest( + path: "/surveys/\(surveyId)/start", + method: "POST" + ) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to start survey") + } + + let result = try JSONDecoder().decode(StartSurveyResponse.self, from: data) + + // Build and cache the response + let surveyResponse = BLSurveyResponse( + id: result.responseId, + surveyId: surveyId, + userId: "", // Will be filled by server + answers: result.answers, + currentQuestionIndex: result.currentQuestionIndex, + startedAt: result.startedAt, + completedAt: nil, + isComplete: false, + incentiveClaimed: false, + incentiveClaimedAt: nil, + createdAt: result.startedAt, + updatedAt: result.startedAt + ) + + cachedResponses[surveyId] = surveyResponse + return surveyResponse + } + + /// Submit an answer to a survey question. + public func submitAnswer( + surveyId: String, + questionId: String, + answer: BLSurveyAnswer + ) async throws -> BLSurveyResponse { + let request = try platformClient.buildRequest( + path: "/surveys/\(surveyId)/response", + method: "POST", + body: [ + "questionId": questionId, + "answer": [ + "type": answer.type, + "value": encodeAnswerValue(answer) + ] + ] + ) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to submit answer") + } + + let result = try JSONDecoder().decode(SubmitAnswerResponse.self, from: data) + + // Update cache + if var cached = cachedResponses[surveyId] { + cached.answers = result.answers + cached.currentQuestionIndex = result.currentQuestionIndex + cachedResponses[surveyId] = cached + } + + // Return updated response + return BLSurveyResponse( + id: result.responseId, + surveyId: surveyId, + userId: "", + answers: result.answers, + currentQuestionIndex: result.currentQuestionIndex, + startedAt: "", + completedAt: nil, + isComplete: false, + incentiveClaimed: false, + incentiveClaimedAt: nil, + createdAt: "", + updatedAt: Date().ISO8601Format() + ) + } + + /// Complete a survey. + public func completeSurvey(surveyId: String) async throws -> SurveyCompletionResult { + let request = try platformClient.buildRequest( + path: "/surveys/\(surveyId)/complete", + method: "POST" + ) + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to complete survey") + } + + let result = try JSONDecoder().decode(SurveyCompletionResult.self, from: data) + + // Clear cache on completion + cachedResponses.removeValue(forKey: surveyId) + + return result + } + + /// Dismiss a survey (won't show again). + public func dismissSurvey(surveyId: String) async throws { + let request = try platformClient.buildRequest( + path: "/surveys/\(surveyId)/dismiss", + method: "POST" + ) + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw BLPlatformError.requestFailed("Failed to dismiss survey") + } + + // Clear cache + cachedResponses.removeValue(forKey: surveyId) + } + + /// Get cached response for a survey. + public func getCachedResponse(surveyId: String) -> BLSurveyResponse? { + return cachedResponses[surveyId] + } + + /// Start polling for eligible surveys. + public func startPolling( + interval: TimeInterval = 60, + onUpdate: @escaping (BLActiveSurvey?) -> Void + ) { + stopPolling() + + pollTask = Task { + while !Task.isCancelled { + do { + let survey = try await getActiveSurvey() + onUpdate(survey) + } catch { + // Silently ignore polling errors + } + + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Stop polling for surveys. + public func stopPolling() { + pollTask?.cancel() + pollTask = nil + } +} + +// MARK: - Survey Completion Result + +public struct SurveyCompletionResult: Codable, Sendable { + public let success: Bool + public let timeSpentSeconds: Int + public let incentiveClaimed: Bool +} + +// MARK: - Response Types + +private struct ActiveSurveyResponse: Codable { + let survey: BLActiveSurvey? +} + +private struct StartSurveyResponse: Codable { + let responseId: String + let startedAt: String + let currentQuestionIndex: Int + let answers: [String: BLSurveyAnswer] +} + +private struct SubmitAnswerResponse: Codable { + let responseId: String + let currentQuestionIndex: Int + let answers: [String: BLSurveyAnswer] +} + +// MARK: - Helper Functions + +private func encodeAnswerValue(_ answer: BLSurveyAnswer) -> AnyCodable { + switch answer { + case .singleChoice(let optionId): + return AnyCodable(optionId) + case .multipleChoice(let optionIds): + return AnyCodable(optionIds) + case .rating(let value), .nps(let value): + return AnyCodable(value) + case .text(let value): + return AnyCodable(value) + case .ranking(let rankedOptionIds): + return AnyCodable(rankedOptionIds) + } +} + +/// Type-erased codable wrapper for encoding heterogeneous types. +private struct AnyCodable: Codable { + private let value: Any + + init(_ value: Any) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let string = value as? String { + try container.encode(string) + } else if let int = value as? Int { + try container.encode(int) + } else if let array = value as? [String] { + try container.encode(array) + } else { + try container.encode(String(describing: value)) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let array = try? container.decode([String].self) { + value = array + } else { + value = "" + } + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift new file mode 100644 index 0000000..57fb887 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift @@ -0,0 +1,592 @@ +import SwiftUI + +/** + * Survey Modal — SwiftUI component for displaying and completing surveys. + * Part of ByteLystPlatformSDK. + */ +@available(iOS 15.0, *) +public struct BLSurveyModal: View { + @ObservedObject var client: BLSurveyClient + @State private var survey: BLActiveSurvey? + @State private var currentQuestionIndex = 0 + @State private var answers: [String: BLSurveyAnswer] = [:] + @State private var isComplete = false + @State private var showCompletion = false + @State private var isPresented = false + + // Local state for question answers + @State private var selectedOption: String? + @State private var selectedOptions: Set = [] + @State private var ratingValue: Int = 0 + @State private var textAnswer: String = "" + @State private var rankingOrder: [String] = [] + + public init(client: BLSurveyClient) { + self.client = client + } + + public var body: some View { + EmptyView() + .sheet(isPresented: $isPresented) { + surveyContent + } + .task { + await checkForSurvey() + startPolling() + } + } + + @ViewBuilder + private var surveyContent: some View { + if showCompletion { + CompletionView( + survey: survey, + onDismiss: { dismissSurvey() } + ) + } else if let survey = survey, currentQuestionIndex < survey.questions.count { + let question = survey.questions[currentQuestionIndex] + QuestionView( + survey: survey, + question: question, + questionIndex: currentQuestionIndex, + totalQuestions: survey.questions.count, + selectedOption: $selectedOption, + selectedOptions: $selectedOptions, + ratingValue: $ratingValue, + textAnswer: $textAnswer, + rankingOrder: $rankingOrder, + onSubmit: { await submitAnswer(question) }, + onSkip: { await skipQuestion() }, + onDismiss: { dismissSurvey() } + ) + } + } + + private func checkForSurvey() async { + do { + if let activeSurvey = try await client.getActiveSurvey() { + survey = activeSurvey + if !isPresented { + isPresented = true + } + } + } catch { + // Silently ignore + } + } + + private func startPolling() { + client.startPolling(interval: 60) { newSurvey in + if let newSurvey = newSurvey, self.survey == nil { + self.survey = newSurvey + self.isPresented = true + } + } + } + + private func submitAnswer(_ question: BLSurveyQuestion) async { + let answer: BLSurveyAnswer + + switch question.type { + case .singleChoice, .dropdown: + guard let value = selectedOption else { return } + answer = .singleChoice(optionId: value) + case .multipleChoice: + let values = Array(selectedOptions) + answer = .multipleChoice(optionIds: values) + case .rating, .scale: + answer = .rating(value: ratingValue) + case .nps: + answer = .nps(value: ratingValue) + case .textShort, .textLong: + answer = .text(value: textAnswer) + case .ranking: + answer = .ranking(rankedOptionIds: rankingOrder) + } + + do { + let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer) + currentQuestionIndex = response.currentQuestionIndex + answers = response.answers + resetQuestionState() + + if currentQuestionIndex >= survey!.questions.count { + await completeSurvey() + } + } catch { + // Silently ignore submit errors + } + } + + private func skipQuestion() async { + let question = survey!.questions[currentQuestionIndex] + if !question.required { + // Skip by advancing without submitting + currentQuestionIndex += 1 + resetQuestionState() + + if currentQuestionIndex >= survey!.questions.count { + await completeSurvey() + } + } + } + + private func completeSurvey() async { + do { + let completion = try await client.completeSurvey(surveyId: survey!.id) + if completion.success { + isComplete = true + showCompletion = true + } + } catch { + // Silently ignore + } + } + + private func dismissSurvey() { + if let survey = survey { + Task { + try? await client.dismissSurvey(surveyId: survey.id) + } + } + isPresented = false + resetSurvey() + } + + private func resetSurvey() { + survey = nil + currentQuestionIndex = 0 + answers = [:] + isComplete = false + showCompletion = false + resetQuestionState() + } + + private func resetQuestionState() { + selectedOption = nil + selectedOptions = [] + ratingValue = 0 + textAnswer = "" + rankingOrder = [] + } +} + +@available(iOS 15.0, *) +struct QuestionView: View { + let survey: BLActiveSurvey + let question: BLSurveyQuestion + let questionIndex: Int + let totalQuestions: Int + + @Binding var selectedOption: String? + @Binding var selectedOptions: Set + @Binding var ratingValue: Int + @Binding var textAnswer: String + @Binding var rankingOrder: [String] + + let onSubmit: () async -> Void + let onSkip: () async -> Void + let onDismiss: () -> Void + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 24) { + // Progress bar + ProgressView(value: Double(questionIndex + 1), total: Double(totalQuestions)) + .padding(.horizontal) + + Text("Question \(questionIndex + 1) of \(totalQuestions)") + .font(.caption) + .foregroundColor(.secondary) + + // Question text + VStack(alignment: .leading, spacing: 8) { + Text(question.text) + .font(.title3.bold()) + + if let description = question.description { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + } + + if question.required { + Text("Required") + .font(.caption) + .foregroundColor(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + + // Question input based on type + questionInput + + Spacer() + + // Action buttons + VStack(spacing: 12) { + Button(action: { Task { await onSubmit() } }) { + Text(isLast ? "Complete" : "Next") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(canSubmit ? Color.blue : Color.gray) + .cornerRadius(12) + } + .disabled(!canSubmit) + + if !question.required { + Button(action: { Task { await onSkip() } }) { + Text("Skip") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal) + } + .padding(.vertical) + } + .navigationTitle(survey.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Dismiss") { + onDismiss() + } + } + } + } + } + + private var isLast: Bool { + questionIndex == totalQuestions - 1 + } + + private var canSubmit: Bool { + if !question.required { return true } + + switch question.type { + case .singleChoice, .dropdown: + return selectedOption != nil + case .multipleChoice: + return !selectedOptions.isEmpty + case .rating, .scale, .nps: + return ratingValue > 0 + case .textShort, .textLong: + return !textAnswer.isEmpty + case .ranking: + return rankingOrder.count == (question.options?.count ?? 0) + } + } + + @ViewBuilder + private var questionInput: some View { + switch question.type { + case .singleChoice, .dropdown: + SingleChoiceView( + options: question.options ?? [], + selected: $selectedOption + ) + case .multipleChoice: + MultipleChoiceView( + options: question.options ?? [], + selected: $selectedOptions + ) + case .rating, .scale, .nps: + RatingView( + minValue: question.minValue ?? (question.type == .nps ? 0 : 1), + maxValue: question.maxValue ?? (question.type == .nps ? 10 : 5), + rating: $ratingValue + ) + case .textShort, .textLong: + TextAnswerView( + text: $textAnswer, + isLong: question.type == .textLong, + minLength: question.minLength, + maxLength: question.maxLength + ) + case .ranking: + RankingView( + options: question.options ?? [], + order: $rankingOrder + ) + } + } +} + +// MARK: - Question Type Views + +@available(iOS 15.0, *) +struct SingleChoiceView: View { + let options: [BLSurveyOption] + @Binding var selected: String? + + var body: some View { + VStack(spacing: 8) { + ForEach(options) { option in + Button(action: { selected = option.id }) { + HStack { + Text(option.emoji ?? "") + Text(option.text) + .foregroundColor(.primary) + Spacer() + if selected == option.id { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + } else { + Image(systemName: "circle") + .foregroundColor(.secondary) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected == option.id ? Color.blue.opacity(0.1) : Color(.systemGray6)) + ) + } + } + } + .padding(.horizontal) + } +} + +@available(iOS 15.0, *) +struct MultipleChoiceView: View { + let options: [BLSurveyOption] + @Binding var selected: Set + + var body: some View { + VStack(spacing: 8) { + ForEach(options) { option in + Button(action: { toggleOption(option.id) }) { + HStack { + Text(option.emoji ?? "") + Text(option.text) + .foregroundColor(.primary) + Spacer() + if selected.contains(option.id) { + Image(systemName: "checkmark.square.fill") + .foregroundColor(.blue) + } else { + Image(systemName: "square") + .foregroundColor(.secondary) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected.contains(option.id) ? Color.blue.opacity(0.1) : Color(.systemGray6)) + ) + } + } + } + .padding(.horizontal) + } + + private func toggleOption(_ id: String) { + if selected.contains(id) { + selected.remove(id) + } else { + selected.insert(id) + } + } +} + +@available(iOS 15.0, *) +struct RatingView: View { + let minValue: Int + let maxValue: Int + @Binding var rating: Int + + var body: some View { + VStack(spacing: 16) { + HStack(spacing: 8) { + ForEach(minValue...maxValue, id: \.self) { value in + Button(action: { rating = value }) { + Text("\(value)") + .font(.headline) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(rating == value ? Color.blue : Color(.systemGray5)) + ) + .foregroundColor(rating == value ? .white : .primary) + } + } + } + + HStack { + Text("Low") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text("High") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal, 32) + } + } +} + +@available(iOS 15.0, *) +struct TextAnswerView: View { + @Binding var text: String + let isLong: Bool + let minLength: Int? + let maxLength: Int? + + var body: some View { + VStack { + if isLong { + TextEditor(text: $text) + .frame(minHeight: 120) + .padding(8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } else { + TextField("Your answer", text: $text) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + + if let max = maxLength { + Text("\(text.count)/\(max)") + .font(.caption) + .foregroundColor(text.count > max ? .red : .secondary) + } + } + .padding(.horizontal) + } +} + +@available(iOS 15.0, *) +struct RankingView: View { + let options: [BLSurveyOption] + @Binding var order: [String] + + var body: some View { + VStack(spacing: 8) { + ForEach(options) { option in + HStack { + Text("\(order.firstIndex(of: option.id).map { "\($0 + 1)" } ?? "-")") + .font(.caption) + .frame(width: 24, height: 24) + .background( + Circle() + .fill(order.contains(option.id) ? Color.blue : Color(.systemGray5)) + ) + .foregroundColor(order.contains(option.id) ? .white : .secondary) + + Text(option.text) + + Spacer() + + HStack(spacing: 4) { + Button(action: { moveUp(option.id) }) { + Image(systemName: "arrow.up") + } + .disabled(!canMoveUp(option.id)) + + Button(action: { moveDown(option.id) }) { + Image(systemName: "arrow.down") + } + .disabled(!canMoveDown(option.id)) + + Button(action: { addToRanking(option.id) }) { + Image(systemName: "plus") + } + .disabled(order.contains(option.id)) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + .padding(.horizontal) + } + + private func canMoveUp(_ id: String) -> Bool { + guard let index = order.firstIndex(of: id), index > 0 else { return false } + return true + } + + private func canMoveDown(_ id: String) -> Bool { + guard let index = order.firstIndex(of: id), index < order.count - 1 else { return false } + return true + } + + private func moveUp(_ id: String) { + guard let index = order.firstIndex(of: id), index > 0 else { return } + order.swapAt(index, index - 1) + } + + private func moveDown(_ id: String) { + guard let index = order.firstIndex(of: id), index < order.count - 1 else { return } + order.swapAt(index, index + 1) + } + + private func addToRanking(_ id: String) { + if !order.contains(id) { + order.append(id) + } + } +} + +@available(iOS 15.0, *) +struct CompletionView: View { + let survey: BLActiveSurvey? + let onDismiss: () -> Void + + var body: some View { + NavigationView { + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(.green) + + Text("Thank You!") + .font(.title.bold()) + + Text("Your feedback helps us improve.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let incentive = survey?.incentive { + HStack { + Image(systemName: "gift.fill") + .foregroundColor(.green) + Text("You've earned \(incentive.amount) \(incentive.type == "pro_days" ? "Pro Days" : "Credits")!") + .font(.headline) + .foregroundColor(.green) + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + } + + Spacer() + + Button(action: onDismiss) { + Text("Close") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(12) + } + .padding(.horizontal) + } + .padding() + .navigationBarHidden(true) + } + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift new file mode 100644 index 0000000..3bc6a8b --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift @@ -0,0 +1,240 @@ +// ── Sync Engine ────────────────────────────────────────────── +// Generic offline-first sync engine for ByteLyst iOS apps. +// Provides: offline queue, periodic sync, delta pull, batch push. +// Product apps supply a SyncAdapter with their DTO types and endpoints. +// +// This extracts the generic parts of ChronoMind's PlatformSyncManager +// so every product app gets offline sync without reimplementing the +// queue, timer, error handling, and conflict resolution plumbing. + +import Foundation + +// MARK: - Sync Adapter Protocol + +/// Product apps implement this protocol to define their sync endpoints and DTO types. +public protocol BLSyncAdapter { + associatedtype SyncItem: Codable + + /// Pull remote changes since the given date. Return empty array if no changes. + func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [SyncItem] + + /// Push a batch of offline queue items. Return IDs of successfully synced items. + func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult +} + +// MARK: - Offline Queue Item + +/// A queued change waiting to be synced. +public struct BLOfflineQueueItem: Codable { + public let id: String + public let action: BLSyncAction + public let payload: Data? // JSON-encoded product-specific DTO + public let enqueuedAt: Date + + public init(id: String, action: BLSyncAction, payload: Data?, enqueuedAt: Date = Date()) { + self.id = id + self.action = action + self.payload = payload + self.enqueuedAt = enqueuedAt + } +} + +public enum BLSyncAction: String, Codable { + case create + case update + case delete +} + +// MARK: - Sync Results + +public struct BLSyncResult { + public let pulled: [T] + public let syncedIds: [String] + public let conflicts: [BLSyncConflict] + public let error: String? + + public init(pulled: [T], syncedIds: [String] = [], conflicts: [BLSyncConflict] = [], error: String? = nil) { + self.pulled = pulled + self.syncedIds = syncedIds + self.conflicts = conflicts + self.error = error + } +} + +public struct BLSyncConflict: Codable { + public let id: String + public let serverVersion: Int + + public init(id: String, serverVersion: Int) { + self.id = id + self.serverVersion = serverVersion + } +} + +public struct BLBatchResult: Codable { + public let synced: [String] + public let conflicts: [BLSyncConflict] + public let errors: [String] + + public init(synced: [String] = [], conflicts: [BLSyncConflict] = [], errors: [String] = []) { + self.synced = synced + self.conflicts = conflicts + self.errors = errors + } +} + +// MARK: - Sync Engine + +/// Generic sync engine. Product apps create one instance with their SyncAdapter. +public final class BLSyncEngine { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + private let adapter: Adapter + + private let storagePrefix: String + private var syncTask: Task? + private let syncIntervalSec: TimeInterval + + // MARK: - State + + public private(set) var isSyncing = false + public private(set) var lastSyncDate: Date? + public private(set) var pendingChanges: Int = 0 + public private(set) var lastError: String? + + public var syncEnabled: Bool { + didSet { + UserDefaults.standard.set(syncEnabled, forKey: "\(storagePrefix)-sync-enabled") + if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } + } + } + + /// Called when sync state changes (for UI binding). + public var onStateChanged: (() -> Void)? + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + adapter: Adapter, + syncIntervalSec: TimeInterval = 60 + ) { + self.config = config + self.client = client + self.adapter = adapter + self.syncIntervalSec = syncIntervalSec + self.storagePrefix = config.productId + + syncEnabled = UserDefaults.standard.bool(forKey: "\(storagePrefix)-sync-enabled") + + if let date = UserDefaults.standard.object(forKey: "\(storagePrefix)-last-sync") as? Date { + lastSyncDate = date + } + + pendingChanges = loadQueueItems().count + + if syncEnabled { + schedulePeriodicSync() + } + } + + // MARK: - Sync + + /// Full delta sync: pull remote changes, push offline queue. + public func sync() async -> BLSyncResult { + guard syncEnabled, client.authToken != nil else { + return BLSyncResult(pulled: [], error: "Not authenticated or sync disabled") + } + + isSyncing = true + lastError = nil + onStateChanged?() + defer { + isSyncing = false + onStateChanged?() + } + + do { + let pulled = try await adapter.pullDelta(since: lastSyncDate, client: client) + let batchResult = try await pushOfflineQueue() + + lastSyncDate = Date() + UserDefaults.standard.set(lastSyncDate, forKey: "\(storagePrefix)-last-sync") + + return BLSyncResult( + pulled: pulled, + syncedIds: batchResult.synced, + conflicts: batchResult.conflicts, + error: nil + ) + } catch { + lastError = error.localizedDescription + return BLSyncResult(pulled: [], error: error.localizedDescription) + } + } + + // MARK: - Offline Queue + + /// Enqueue a change for later sync. + public func enqueueChange(id: String, action: BLSyncAction, payload: Data?) { + var queue = loadQueueItems() + queue.removeAll { $0.id == id } + queue.append(BLOfflineQueueItem(id: id, action: action, payload: payload)) + saveQueue(queue) + pendingChanges = queue.count + onStateChanged?() + } + + /// Enqueue a delete. + public func enqueueDelete(id: String) { + enqueueChange(id: id, action: .delete, payload: nil) + } + + // MARK: - Private + + private func pushOfflineQueue() async throws -> BLBatchResult { + let queue = loadQueueItems() + guard !queue.isEmpty else { + return BLBatchResult() + } + + let result = try await adapter.pushBatch(queue, client: client) + + // Clear synced items + var remaining = loadQueueItems() + remaining.removeAll { result.synced.contains($0.id) } + saveQueue(remaining) + pendingChanges = remaining.count + + return result + } + + private func schedulePeriodicSync() { + cancelPeriodicSync() + syncTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64((self?.syncIntervalSec ?? 60) * 1_000_000_000)) + guard let self, self.syncEnabled else { break } + _ = await self.sync() + } + } + } + + private func cancelPeriodicSync() { + syncTask?.cancel() + syncTask = nil + } + + // MARK: - Queue Persistence + + private func loadQueueItems() -> [BLOfflineQueueItem] { + guard let data = UserDefaults.standard.data(forKey: "\(storagePrefix)-offline-queue") else { return [] } + return (try? JSONDecoder().decode([BLOfflineQueueItem].self, from: data)) ?? [] + } + + private func saveQueue(_ queue: [BLOfflineQueueItem]) { + if let data = try? JSONEncoder().encode(queue) { + UserDefaults.standard.set(data, forKey: "\(storagePrefix)-offline-queue") + } + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift new file mode 100644 index 0000000..1d5350c --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift @@ -0,0 +1,214 @@ +// ── Telemetry Client ───────────────────────────────────────── +// Generic telemetry event queue + batch flush to platform-service. +// Matches the @bytelyst/telemetry-client TypeScript package interface. +// Product apps configure with BLPlatformConfig; no hardcoded product IDs. + +import Foundation + +/// Telemetry event matching the platform-service /api/telemetry/events schema. +public struct BLTelemetryEvent: Codable, Sendable { + public let id: String + public let productId: String + public let anonymousInstallId: String + public let sessionId: String + public let platform: String + public let channel: String + public let osFamily: String + public let osVersion: String + public let appVersion: String + public let buildNumber: String + public let releaseChannel: String + public let eventType: String + public let module: String + public let eventName: String + public var feature: String? + public var message: String? + public var tags: [String: String]? + public var metrics: [String: Double]? + public let occurredAt: String +} + +/// Generic telemetry client. Queues events in memory and flushes periodically. +/// Thread-safe via NSLock. Fire-and-forget — errors never surface to the user. +public final class BLTelemetryClient { + + private let config: BLPlatformConfig + private let client: BLPlatformClient + + private var queue: [[String: Any]] = [] + private let queueLock = NSLock() + private var flushTimer: Timer? + + private let maxQueue: Int + private let batchSize: Int + private let flushIntervalSec: TimeInterval + + private let installId: String + private var sessionId: String + + private let appVersion: String + private let buildNumber: String + private let releaseChannel: String + private let osVersion: String + + /// Optional extra fields added to every event (e.g. deviceModel, locale, timezone). + public var extraFields: [String: String] = [:] + + public init( + config: BLPlatformConfig, + client: BLPlatformClient, + maxQueue: Int = 200, + batchSize: Int = 50, + flushIntervalSec: TimeInterval = 30, + releaseChannel: String = "beta" + ) { + self.config = config + self.client = client + self.maxQueue = maxQueue + self.batchSize = batchSize + self.flushIntervalSec = flushIntervalSec + self.releaseChannel = releaseChannel + + let bundle = Bundle.main + appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" + osVersion = ProcessInfo.processInfo.operatingSystemVersionString + + // Install ID — persisted in UserDefaults (or App Group if configured) + let storageKey = "\(config.productId)-telemetry-install-id" + let defaults: UserDefaults + if let groupId = config.appGroupId, let groupDefaults = UserDefaults(suiteName: groupId) { + defaults = groupDefaults + } else { + defaults = .standard + } + if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { + installId = existing + } else { + let newId = UUID().uuidString + defaults.set(newId, forKey: storageKey) + installId = newId + } + + sessionId = UUID().uuidString + } + + // MARK: - Lifecycle + + /// Start the periodic flush timer. Call on app launch / foreground. + public func start() { + sessionId = UUID().uuidString + guard flushTimer == nil else { return } + flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in + self?.flush() + } + } + + /// Stop the flush timer and flush remaining events. Call on app background. + public func stop() { + flush() + flushTimer?.invalidate() + flushTimer = nil + } + + // MARK: - Track + + /// Track a telemetry event. Thread-safe. + public func trackEvent( + _ eventType: String, + module: String, + name: String, + feature: String? = nil, + message: String? = nil, + tags: [String: String]? = nil, + metrics: [String: Double]? = nil, + userId: String? = nil + ) { + var event: [String: Any] = [ + "id": UUID().uuidString, + "productId": config.productId, + "anonymousInstallId": installId, + "sessionId": sessionId, + "platform": config.platform, + "channel": config.channel, + "osFamily": "ios", + "osVersion": osVersion, + "appVersion": appVersion, + "buildNumber": buildNumber, + "releaseChannel": releaseChannel, + "eventType": eventType, + "module": module, + "eventName": name, + "occurredAt": ISO8601DateFormatter().string(from: Date()), + ] + + if let feature { event["feature"] = feature } + if let message { event["message"] = String(message.prefix(512)) } + if let tags { event["tags"] = tags } + if let metrics { event["metrics"] = metrics } + if let userId { event["userId"] = userId } + + for (key, value) in extraFields { + event[key] = value + } + + enqueue(event) + } + + /// Convenience: track a screen view. + public func trackScreen(_ screen: String) { + trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) + } + + // MARK: - Flush + + /// Flush all queued events to the server. Thread-safe. + public func flush() { + queueLock.lock() + let events = queue + queue.removeAll() + queueLock.unlock() + + guard !events.isEmpty else { return } + + // Batch into chunks + let chunks = stride(from: 0, to: events.count, by: batchSize).map { + Array(events[$0.. String { installId } + public func getSessionId() -> String { sessionId } + + // MARK: - Private + + private func enqueue(_ event: [String: Any]) { + queueLock.lock() + queue.append(event) + if queue.count > maxQueue { + queue.removeFirst(queue.count - maxQueue) + } + let count = queue.count + queueLock.unlock() + + if count >= batchSize { + flush() + } + } + + private func sendBatch(_ events: [[String: Any]]) { + let body: [String: Any] = [ + "productId": config.productId, + "events": events, + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } + client.fireAndForget(path: "/api/telemetry/events", body: jsonData) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift new file mode 100644 index 0000000..1e92073 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift @@ -0,0 +1,122 @@ +// ── ByteLystPlatform ──────────────────────────────────────── +// Unified entry point for the ByteLyst platform SDK. +// Creates and wires all platform services from a single config. +// +// Usage: +// let platform = ByteLystPlatform(config: .init( +// productId: "peakpulse", +// baseURL: "https://api.peakpulse.app", +// bundleId: "com.saravana.peakpulse" +// )) +// +// platform.start() // Start telemetry + flags + kill switch +// platform.telemetry.trackScreen("home") // Track events +// let isNew = platform.flags.isEnabled("new_feature") +// platform.stop() // Flush + stop timers + +import Foundation + +/// Unified entry point that wires all ByteLyst platform services together. +/// Create one instance at app launch and access services via properties. +public final class ByteLystPlatform { + + /// Platform configuration. + public let config: BLPlatformConfig + + /// HTTP client shared by all services. + public let client: BLPlatformClient + + /// Telemetry event tracking. + public let telemetry: BLTelemetryClient + + /// Feature flag polling. + public let flags: BLFeatureFlagClient + + /// Kill switch checker. + public let killSwitch: BLKillSwitchClient + + /// Crash reporter (MetricKit). Created lazily on MainActor. + public private(set) var crashReporter: BLCrashReporter? + + /// Keychain access (via bundleId as service). + public let keychain: BLKeychainAccessor + + /// Audit logger type (static API — call BLAuditLogger.log()). + public let auditLog: BLAuditLogger.Type = BLAuditLogger.self + + /// Auth client. + public let auth: BLAuthClient + + /// Whether `start()` has been called. + public private(set) var isStarted = false + + public init(config: BLPlatformConfig) { + self.config = config + self.client = BLPlatformClient(config: config) + self.telemetry = BLTelemetryClient(config: config, client: client) + self.flags = BLFeatureFlagClient(config: config, client: client) + self.killSwitch = BLKillSwitchClient(config: config) + self.keychain = BLKeychainAccessor(service: config.bundleId) + self.auth = BLAuthClient(config: config, client: client) + BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") + } + + /// Test-only initializer that accepts a custom URLSessionConfiguration. + public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { + self.config = config + self.client = BLPlatformClient(config: config, sessionConfiguration: sessionConfiguration) + self.telemetry = BLTelemetryClient(config: config, client: client) + self.flags = BLFeatureFlagClient(config: config, client: client) + self.killSwitch = BLKillSwitchClient(config: config) + self.keychain = BLKeychainAccessor(service: config.bundleId) + self.auth = BLAuthClient(config: config, client: client) + BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") + } + + // MARK: - Lifecycle + + /// Start all services: telemetry flush timer, feature flag polling, kill switch check. + public func start(userId: String? = nil) { + guard !isStarted else { return } + isStarted = true + telemetry.start() + flags.start(userId: userId) + Task { await killSwitch.check() } + Task { @MainActor in + self.crashReporter = BLCrashReporter(productId: config.productId) + } + } + + /// Stop all services: flush telemetry, stop flag polling. + public func stop() { + guard isStarted else { return } + isStarted = false + telemetry.stop() + flags.stop() + } +} + +// MARK: - Keychain Accessor + +/// Convenience wrapper around BLKeychain that binds to a specific service (bundleId). +public struct BLKeychainAccessor { + private let service: String + + public init(service: String) { + self.service = service + } + + @discardableResult + public func save(key: String, value: String) -> Bool { + BLKeychain.save(service: service, key: key, value: value) + } + + public func read(key: String) -> String? { + BLKeychain.read(service: service, key: key) + } + + @discardableResult + public func delete(key: String) -> Bool { + BLKeychain.delete(service: service, key: key) + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift new file mode 100644 index 0000000..8da8a2a --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift @@ -0,0 +1,34 @@ +// ── ByteLystPlatformSDK ────────────────────────────────────── +// Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. +// Re-exports all public types via their respective source files. +// +// Usage in product apps: +// import ByteLystPlatformSDK +// +// let config = BLPlatformConfig(productId: "peakpulse", baseURL: "...", bundleId: "com.saravana.peakpulse") +// let client = BLPlatformClient(config: config) +// let telemetry = BLTelemetryClient(config: config, client: client) +// let auth = BLAuthClient(config: config, client: client) +// let flags = BLFeatureFlagClient(config: config, client: client) +// let blob = BLBlobClient(config: config, client: client) +// let license = BLLicenseClient(config: config, client: client) +// let killSwitch = BLKillSwitchClient(config: config) +// let crashReporter = BLCrashReporter(productId: config.productId) +// +// Components (13 source files): +// - BLPlatformConfig — Product-specific config (productId, baseURL, bundleId, appGroupId) +// - BLPlatformClient — Generic HTTP client (auth injection, x-request-id, fire-and-forget) +// - BLKeychain — Keychain CRUD (configurable service string) +// - BLTelemetryClient — Telemetry event queue + batch flush +// - BLAuthClient — Full auth operations (login, register, refresh, password ops) +// - BLFeatureFlagClient — Feature flag polling from /api/flags/poll +// - BLSyncEngine — Generic offline-first sync with BLSyncAdapter protocol +// - BLBlobClient — Azure Blob Storage upload via SAS tokens +// - BLKillSwitchClient — Kill switch check from platform-service +// - BLLicenseClient — License key activation via platform-service +// - BLBiometricAuth — Face ID / Touch ID wrapper +// - BLCrashReporter — MetricKit crash and performance reporting +// - BLAuditLogger — Local rotating JSON audit log + +// All types are exported via their respective files. +// This file exists for module-level documentation only. diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift new file mode 100644 index 0000000..c9ef73c --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift @@ -0,0 +1,196 @@ +// ── BLAuthClient SmartAuth v2 Tests ───────────────────────── +// Tests for social login, MFA verify methods. +// Uses URLProtocol mocking to intercept network requests. + +import XCTest +@testable import ByteLystPlatformSDK + +// MARK: - Mock URL Protocol + +private class MockURLProtocol: URLProtocol { + static var mockResponses: [String: (Data, Int)] = [:] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let path = request.url?.path ?? "" + let method = request.httpMethod ?? "GET" + let key = "\(method) \(path)" + + if let (data, statusCode) = MockURLProtocol.mockResponses[key] { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + } else { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 404, + httpVersion: nil, + headerFields: nil + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + } + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +// MARK: - Tests + +final class BLAuthClientSmartAuthTests: XCTestCase { + + private var config: BLPlatformConfig! + private var client: BLPlatformClient! + private var authClient: BLAuthClient! + + override func setUp() { + super.setUp() + config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + platform: "ios", + channel: "test", + bundleId: "com.test.smartauth" + ) + + // Configure URLSession with mock protocol wired into BLPlatformClient + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [MockURLProtocol.self] + + client = BLPlatformClient(config: config, sessionConfiguration: sessionConfig) + authClient = BLAuthClient(config: config, client: client) + + MockURLProtocol.mockResponses.removeAll() + } + + override func tearDown() { + MockURLProtocol.mockResponses.removeAll() + // Clean up keychain + BLKeychain.delete(service: "com.test.smartauth", key: "access_token") + BLKeychain.delete(service: "com.test.smartauth", key: "refresh_token") + super.tearDown() + } + + // MARK: - Test 1: Swift Social Login (Google) + + func testLoginWithGoogleCallsCorrectEndpoint() async throws { + // Mock a successful token response for Google OAuth + let tokenJSON = """ + {"accessToken":"at_123","refreshToken":"rt_456","user":{"id":"u1","email":"test@google.com","displayName":"Test User","plan":"free","role":"user"}} + """ + MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (tokenJSON.data(using: .utf8)!, 200) + + let user = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") + XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.email, "test@google.com") + XCTAssertEqual(user.displayName, "Test User") + } + + func testLoginWithGoogleDetectsMfaChallenge() async { + // Mock an MFA challenge response for Google OAuth + let mfaJSON = """ + {"mfaRequired":true,"mfaChallenge":"ch_google_123","methods":["totp"]} + """ + MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (mfaJSON.data(using: .utf8)!, 200) + + do { + _ = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") + XCTFail("Should have thrown BLAuthError.mfaRequired") + } catch let error as BLAuthError { + if case .mfaRequired(let challenge) = error { + XCTAssertEqual(challenge.mfaChallenge, "ch_google_123") + XCTAssertEqual(challenge.methods, ["totp"]) + } else { + XCTFail("Expected mfaRequired error") + } + } + } + + // MARK: - Test 2: Swift MFA Verify + + func testVerifyMfaCallsCorrectEndpoint() async throws { + // Mock a successful MFA verification response + let tokenJSON = """ + {"accessToken":"at_mfa","refreshToken":"rt_mfa","user":{"id":"u2","email":"mfa@test.com","displayName":"MFA User"}} + """ + MockURLProtocol.mockResponses["POST /api/auth/mfa/verify"] = (tokenJSON.data(using: .utf8)!, 200) + + let user = try await authClient.verifyMfa( + challengeToken: "mock_challenge_token", + code: "123456", + method: "totp" + ) + XCTAssertEqual(user.id, "u2") + XCTAssertEqual(user.email, "mfa@test.com") + } + + // MARK: - Type Verification Tests + + func testSmartAuthTypesAreDecodable() throws { + // BLMfaChallenge + let challengeJSON = """ + {"mfaRequired":true,"mfaChallenge":"ch_abc123","methods":["totp"]} + """ + let challenge = try JSONDecoder().decode(BLMfaChallenge.self, from: challengeJSON.data(using: .utf8)!) + XCTAssertTrue(challenge.mfaRequired) + XCTAssertEqual(challenge.mfaChallenge, "ch_abc123") + XCTAssertEqual(challenge.methods, ["totp"]) + + // BLMfaStatus + let statusJSON = """ + {"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6} + """ + let status = try JSONDecoder().decode(BLMfaStatus.self, from: statusJSON.data(using: .utf8)!) + XCTAssertTrue(status.mfaEnabled) + XCTAssertEqual(status.recoveryCodesRemaining, 6) + + // BLAuthProvider + let providerJSON = """ + {"provider":"google","email":"test@test.com","linkedAt":"2026-01-01T00:00:00Z","lastUsedAt":null} + """ + let provider = try JSONDecoder().decode(BLAuthProvider.self, from: providerJSON.data(using: .utf8)!) + XCTAssertEqual(provider.provider, "google") + XCTAssertNil(provider.lastUsedAt) + + // BLDevice + let deviceJSON = """ + {"id":"dev_1","name":"iPhone 16","platform":"ios","trustLevel":"trusted","trustExpiresAt":"2026-06-01T00:00:00Z","lastLoginAt":"2026-03-01T00:00:00Z"} + """ + let device = try JSONDecoder().decode(BLDevice.self, from: deviceJSON.data(using: .utf8)!) + XCTAssertEqual(device.trustLevel, "trusted") + XCTAssertEqual(device.platform, "ios") + + // BLLoginEvent + let eventJSON = """ + {"id":"evt_1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01T00:00:00Z"} + """ + let event = try JSONDecoder().decode(BLLoginEvent.self, from: eventJSON.data(using: .utf8)!) + XCTAssertEqual(event.riskScore, 15) + XCTAssertEqual(event.geo?.city, "SF") + } + + func testAuthStateIncludesMfaRequired() { + let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp"]) + let state = BLAuthState.mfaRequired(challenge) + + if case .mfaRequired(let c) = state { + XCTAssertEqual(c.mfaChallenge, "ch_test") + } else { + XCTFail("Expected mfaRequired state") + } + } + + func testBLAuthErrorMfaRequired() { + let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp", "recovery"]) + let error = BLAuthError.mfaRequired(challenge) + XCTAssertEqual(error.localizedDescription, "Multi-factor authentication required") + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift new file mode 100644 index 0000000..4f63401 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLFeatureFlagClientTests: XCTestCase { + + private func makeClient() -> BLFeatureFlagClient { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + let platformClient = BLPlatformClient(config: config) + return BLFeatureFlagClient(config: config, client: platformClient) + } + + func testDefaultFlagsEmpty() { + let client = makeClient() + XCTAssertEqual(client.allFlags().count, 0) + } + + func testIsEnabledReturnsFalseForUnknownKey() { + let client = makeClient() + XCTAssertFalse(client.isEnabled("nonexistent_flag")) + } + + func testStopDoesNotCrash() { + let client = makeClient() + client.stop() + client.stop() // Double stop + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift new file mode 100644 index 0000000..5180c17 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift @@ -0,0 +1,186 @@ +import XCTest +import CryptoKit +@testable import ByteLystPlatformSDK + +final class BLFieldEncryptTests: XCTestCase { + + private var key: SymmetricKey! + private let dekId = "dek_user1_test" + + override func setUp() { + super.setUp() + key = BLFieldEncrypt.generateKey() + } + + // MARK: - Encrypt / Decrypt Roundtrip + + func testEncryptDecryptRoundtrip() throws { + let plaintext = "Hello, World!" + let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) + XCTAssertEqual(decrypted, plaintext) + } + + func testEncryptDecryptEmptyString() throws { + let plaintext = "" + let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) + XCTAssertEqual(decrypted, plaintext) + } + + func testEncryptDecryptUnicode() throws { + let plaintext = "こんにちは世界 🌍 مرحبا Ñoño" + let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) + XCTAssertEqual(decrypted, plaintext) + } + + func testEncryptDecryptLargePayload() throws { + let plaintext = String(repeating: "A", count: 100_000) + let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) + XCTAssertEqual(decrypted, plaintext) + } + + // MARK: - EncryptedField Structure + + func testEncryptedFieldHasCorrectSentinel() throws { + let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) + XCTAssertTrue(encrypted.__encrypted) + XCTAssertEqual(encrypted.v, 1) + XCTAssertEqual(encrypted.alg, "aes-256-gcm") + XCTAssertEqual(encrypted.dekId, dekId) + } + + func testEncryptedFieldHasCorrectHexLengths() throws { + let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) + // IV: 12 bytes = 24 hex chars + XCTAssertEqual(encrypted.iv.count, 24) + // Tag: 16 bytes = 32 hex chars + XCTAssertEqual(encrypted.tag.count, 32) + // ct should be non-empty hex + XCTAssertFalse(encrypted.ct.isEmpty) + } + + func testEachEncryptionProducesUniqueIV() throws { + let a = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) + let b = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) + XCTAssertNotEqual(a.iv, b.iv, "Each encryption should use a unique IV") + XCTAssertNotEqual(a.ct, b.ct, "Ciphertext should differ with different IVs") + } + + // MARK: - AAD (Additional Authenticated Data) + + func testEncryptDecryptWithAAD() throws { + let plaintext = "secret data" + let aad = "user123:notes" + let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId, aad: aad) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key, aad: aad) + XCTAssertEqual(decrypted, plaintext) + } + + func testDecryptWithWrongAADFails() throws { + let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "correct") + XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key, aad: "wrong")) + } + + func testDecryptWithMissingAADFails() throws { + let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "some-aad") + XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key)) + } + + // MARK: - Wrong Key + + func testDecryptWithWrongKeyFails() throws { + let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId) + let wrongKey = BLFieldEncrypt.generateKey() + XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: wrongKey)) + } + + // MARK: - Key Size Validation + + func testEncryptRejectsShortKey() { + let shortKey = SymmetricKey(size: .bits128) + XCTAssertThrowsError(try BLFieldEncrypt.encrypt("test", key: shortKey, dekId: dekId)) { error in + guard let encError = error as? BLFieldEncryptError, + case .invalidKeySize = encError else { + XCTFail("Expected invalidKeySize error") + return + } + } + } + + // MARK: - Key from Hex + + func testKeyFromHex() throws { + let hex = String(repeating: "ab", count: 32) // 64 hex chars = 32 bytes + let key = try BLFieldEncrypt.keyFromHex(hex) + let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) + let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) + XCTAssertEqual(decrypted, "test") + } + + func testKeyFromHexRejectsInvalidLength() { + XCTAssertThrowsError(try BLFieldEncrypt.keyFromHex("aabb")) + } + + // MARK: - isEncrypted Type Guard + + func testIsEncryptedWithValidField() throws { + let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) + XCTAssertTrue(BLFieldEncrypt.isEncrypted(encrypted)) + } + + func testIsEncryptedWithNil() { + XCTAssertFalse(BLFieldEncrypt.isEncrypted(nil as BLEncryptedField?)) + } + + func testIsEncryptedWithDictionary() { + let dict: [String: Any] = [ + "__encrypted": true, + "v": 1, + "alg": "aes-256-gcm", + "ct": "abcd", + "iv": "1234", + "tag": "5678", + "dekId": "dek_test", + ] + XCTAssertTrue(BLFieldEncrypt.isEncrypted(dict)) + } + + func testIsEncryptedWithPlainString() { + XCTAssertFalse(BLFieldEncrypt.isEncrypted("just a string" as Any)) + } + + func testIsEncryptedWithIncompleteDict() { + let dict: [String: Any] = ["__encrypted": true, "v": 1] + XCTAssertFalse(BLFieldEncrypt.isEncrypted(dict)) + } + + // MARK: - JSON Codable Roundtrip + + func testEncryptedFieldCodableRoundtrip() throws { + let encrypted = try BLFieldEncrypt.encrypt("codable test", key: key, dekId: dekId) + let encoder = JSONEncoder() + let data = try encoder.encode(encrypted) + let decoder = JSONDecoder() + let decoded = try decoder.decode(BLEncryptedField.self, from: data) + let decrypted = try BLFieldEncrypt.decrypt(decoded, key: key) + XCTAssertEqual(decrypted, "codable test") + } + + // MARK: - Hex Helpers + + func testDataHexRoundtrip() { + let original = Data([0x00, 0x0f, 0xff, 0xab, 0xcd]) + let hex = original.hexString + XCTAssertEqual(hex, "000fffabcd") + let restored = Data(hexString: hex) + XCTAssertEqual(restored, original) + } + + func testDataHexInvalidString() { + XCTAssertNil(Data(hexString: "xyz")) + XCTAssertNil(Data(hexString: "a")) // odd length + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift new file mode 100644 index 0000000..046f09a --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLKeychainTests: XCTestCase { + + private let service = "com.bytelyst.test" + + override func tearDown() { + BLKeychain.delete(service: service, key: "test_key") + super.tearDown() + } + + func testSaveAndRead() { + BLKeychain.save(service: service, key: "test_key", value: "hello") + XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "hello") + } + + func testReadNonExistent() { + XCTAssertNil(BLKeychain.read(service: service, key: "nonexistent")) + } + + func testDelete() { + BLKeychain.save(service: service, key: "test_key", value: "hello") + BLKeychain.delete(service: service, key: "test_key") + XCTAssertNil(BLKeychain.read(service: service, key: "test_key")) + } + + func testOverwrite() { + BLKeychain.save(service: service, key: "test_key", value: "first") + BLKeychain.save(service: service, key: "test_key", value: "second") + XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "second") + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift new file mode 100644 index 0000000..e018b0c --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLKillSwitchClientTests: XCTestCase { + + private func makeConfig() -> BLPlatformConfig { + BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + } + + func testDefaultState() { + let client = BLKillSwitchClient(config: makeConfig()) + XCTAssertFalse(client.isDisabled) + XCTAssertEqual(client.maintenanceMessage, "") + } + + func testReset() { + let client = BLKillSwitchClient(config: makeConfig()) + // Manually test reset clears any hypothetical state + client.reset() + XCTAssertFalse(client.isDisabled) + XCTAssertEqual(client.maintenanceMessage, "") + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift new file mode 100644 index 0000000..f199d7c --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLPlatformConfigTests: XCTestCase { + + func testInitWithDefaults() { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.testapp" + ) + XCTAssertEqual(config.productId, "testapp") + XCTAssertEqual(config.baseURL, "http://localhost:4003") + XCTAssertEqual(config.platform, "ios") + XCTAssertEqual(config.channel, "native") + XCTAssertEqual(config.bundleId, "com.bytelyst.testapp") + XCTAssertNil(config.appGroupId) + } + + func testInitWithCustomValues() { + let config = BLPlatformConfig( + productId: "peakpulse", + baseURL: "https://api.peakpulse.app", + platform: "watchos", + channel: "companion", + bundleId: "com.saravana.peakpulse", + appGroupId: "group.com.saravana.peakpulse" + ) + XCTAssertEqual(config.productId, "peakpulse") + XCTAssertEqual(config.platform, "watchos") + XCTAssertEqual(config.channel, "companion") + XCTAssertEqual(config.appGroupId, "group.com.saravana.peakpulse") + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift new file mode 100644 index 0000000..dc2d534 --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class BLTelemetryClientTests: XCTestCase { + + private func makeClient() -> BLTelemetryClient { + let config = BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003", + bundleId: "com.bytelyst.test" + ) + let platformClient = BLPlatformClient(config: config) + return BLTelemetryClient(config: config, client: platformClient) + } + + func testInstallIdIsStable() { + let client = makeClient() + let id1 = client.getInstallId() + let id2 = client.getInstallId() + XCTAssertEqual(id1, id2) + XCTAssertFalse(id1.isEmpty) + } + + func testSessionIdIsNotEmpty() { + let client = makeClient() + XCTAssertFalse(client.getSessionId().isEmpty) + } + + func testStartGeneratesNewSessionId() { + let client = makeClient() + let sessionBefore = client.getSessionId() + client.start() + let sessionAfter = client.getSessionId() + // start() rotates session ID + XCTAssertNotEqual(sessionBefore, sessionAfter) + client.stop() + } + + func testStopDoesNotCrash() { + let client = makeClient() + client.stop() // Stop without start + client.start() + client.stop() + client.stop() // Double stop + } + + func testTrackEventDoesNotCrash() { + let client = makeClient() + client.trackEvent("info", module: "test", name: "unit_test") + client.trackEvent("error", module: "test", name: "fail", message: "test error") + client.trackEvent("info", module: "test", name: "tagged", tags: ["key": "value"]) + client.trackEvent("info", module: "test", name: "measured", metrics: ["duration": 1.5]) + } + + func testTrackScreenDoesNotCrash() { + let client = makeClient() + client.trackScreen("home") + client.trackScreen("settings") + } + + func testFlushDoesNotCrashWhenEmpty() { + let client = makeClient() + client.flush() // Empty queue + } +} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift new file mode 100644 index 0000000..065c6fd --- /dev/null +++ b/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import ByteLystPlatformSDK + +final class ByteLystPlatformTests: XCTestCase { + + private func makeConfig() -> BLPlatformConfig { + BLPlatformConfig( + productId: "testapp", + baseURL: "http://localhost:4003/api", + platform: "ios", + channel: "native", + bundleId: "com.bytelyst.test" + ) + } + + func testInitCreatesAllServices() { + let platform = ByteLystPlatform(config: makeConfig()) + XCTAssertEqual(platform.config.productId, "testapp") + XCTAssertNotNil(platform.client) + XCTAssertNotNil(platform.telemetry) + XCTAssertNotNil(platform.flags) + XCTAssertNotNil(platform.killSwitch) + XCTAssertNotNil(platform.crashReporter) + XCTAssertNotNil(platform.keychain) + XCTAssertNotNil(platform.auditLog) + XCTAssertNotNil(platform.auth) + } + + func testStartSetsIsStarted() { + let platform = ByteLystPlatform(config: makeConfig()) + XCTAssertFalse(platform.isStarted) + platform.start() + XCTAssertTrue(platform.isStarted) + } + + func testStopClearsIsStarted() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + XCTAssertTrue(platform.isStarted) + platform.stop() + XCTAssertFalse(platform.isStarted) + } + + func testDoubleStartIsIdempotent() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + platform.start() // Should not crash or double-start + XCTAssertTrue(platform.isStarted) + platform.stop() + } + + func testDoubleStopIsIdempotent() { + let platform = ByteLystPlatform(config: makeConfig()) + platform.start() + platform.stop() + platform.stop() // Should not crash + XCTAssertFalse(platform.isStarted) + } + + func testKeychainAccessor() { + let accessor = BLKeychainAccessor(service: "com.bytelyst.test.accessor") + accessor.save(key: "test_key", value: "hello") + XCTAssertEqual(accessor.read(key: "test_key"), "hello") + accessor.delete(key: "test_key") + XCTAssertNil(accessor.read(key: "test_key")) + } +} diff --git a/vendor/bytelyst/sync/package.json b/vendor/bytelyst/sync/package.json new file mode 100644 index 0000000..a205f63 --- /dev/null +++ b/vendor/bytelyst/sync/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bytelyst/sync", + "version": "0.1.5", + "description": "Offline-first sync engine with configurable storage adapters and conflict resolution", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/api-client": "workspace:*", + "@bytelyst/telemetry-client": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "@bytelyst/api-client": "workspace:*" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/sync/src/engine.ts b/vendor/bytelyst/sync/src/engine.ts new file mode 100644 index 0000000..277e2e0 --- /dev/null +++ b/vendor/bytelyst/sync/src/engine.ts @@ -0,0 +1,603 @@ +/** + * Sync Engine — Core implementation + * + * Offline-first sync with: + * - Queue persistence via pluggable StorageAdapter + * - Deduplication (collapse updates to same entity + id) + * - Exponential backoff retry (configurable base/max delay) + * - Conflict detection via HTTP 409 + configurable resolution strategies + * - Connectivity detection with auto-flush on reconnect + * - Telemetry integration for sync success/failure/conflict tracking + * - onPull callback so consumers merge pulled data into local store + * + * @module @bytelyst/sync/engine + */ + +import type { + SyncEngine, + SyncEngineConfig, + SyncItem, + SyncResult, + SyncStatus, + SyncStatusInfo, + SyncStatusCallback, + EntityName, + SyncOperation, + ConflictStrategy, + Conflict, +} from './types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_RETRY_BASE_DELAY_MS = 1000; +const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; +const QUEUE_KEY = 'queue'; +const LAST_SYNC_KEY = 'lastSync'; + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP 409 Conflict Error +// ───────────────────────────────────────────────────────────────────────────── + +export class SyncConflictError extends Error { + constructor(public remoteData: unknown) { + super('Sync conflict: server has newer version'); + this.name = 'SyncConflictError'; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Compute exponential backoff with jitter: base * 2^attempt + random jitter */ +export function computeBackoff(attempt: number, baseMs: number, maxMs: number): number { + const delay = Math.min(baseMs * Math.pow(2, attempt), maxMs); + const jitter = delay * 0.1 * Math.random(); + return delay + jitter; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sync Engine Implementation +// ───────────────────────────────────────────────────────────────────────────── + +export class SyncEngineImpl implements SyncEngine { + private config: Required< + Pick + > & + SyncEngineConfig; + private status: SyncStatus = 'idle'; + private queueLength = 0; + private lastSyncAt?: string; + private lastError?: string; + private statusListeners: Set = new Set(); + private onlineHandler: (() => void) | null = null; + private offlineHandler: (() => void) | null = null; + private destroyed = false; + + constructor(config: SyncEngineConfig) { + this.config = { + maxRetries: DEFAULT_MAX_RETRIES, + retryBaseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS, + retryMaxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS, + ...config, + }; + this.setupConnectivityDetection(); + } + + // ─────────────────────────────────────────────────────────────────────────── + // Core Operations + // ─────────────────────────────────────────────────────────────────────────── + + async push( + entity: EntityName, + data: unknown, + operation: SyncOperation = 'create' + ): Promise { + const item: SyncItem = { + id: this.generateId(), + entity, + operation, + data, + timestamp: new Date().toISOString(), + retryCount: 0, + }; + + const existingQueue = await this.getQueue(); + const dedupKey = this.getDedupKey(entity, data); + const existingIndex = existingQueue.findIndex( + i => this.getDedupKey(i.entity, i.data) === dedupKey && i.operation === operation + ); + + if (existingIndex >= 0) { + existingQueue[existingIndex] = item; + } else { + existingQueue.push(item); + } + + await this.saveQueue(existingQueue); + this.notifyStatus(); + } + + async delete(entity: EntityName, id: string): Promise { + // Also remove any pending create/update for this entity+id + const queue = await this.getQueue(); + const dedupKey = `${entity}:${id}`; + const filtered = queue.filter(i => this.getDedupKey(i.entity, i.data) !== dedupKey); + + const item: SyncItem = { + id: this.generateId(), + entity, + operation: 'delete', + data: { id }, + timestamp: new Date().toISOString(), + retryCount: 0, + }; + filtered.push(item); + + await this.saveQueue(filtered); + this.notifyStatus(); + } + + async pull(): Promise { + const result = this.emptyResult(); + this.setStatus('syncing'); + + try { + for (const [entityName, entityConfig] of Object.entries(this.config.entities)) { + try { + const count = await this.pullEntity(entityName, entityConfig.endpoint); + result.pulled += count; + } catch (error) { + result.errors++; + this.trackTelemetry('sync_pull_error', entityName, error); + } + } + + result.timestamp = new Date().toISOString(); + await this.setLastSyncTime(result.timestamp); + this.lastSyncAt = result.timestamp; + this.setStatus(result.errors > 0 ? 'error' : 'idle'); + } catch (error) { + result.success = false; + this.setStatus('error', error instanceof Error ? error.message : 'Unknown error'); + } + + return result; + } + + async fullSync(): Promise { + if (!this.isOnline()) { + this.setStatus('offline'); + return { ...this.emptyResult(), success: false }; + } + + const pushResult = await this.pushQueue(); + const pullResult = await this.pull(); + + const combined: SyncResult = { + success: pushResult.success && pullResult.success, + pushed: pushResult.pushed, + pulled: pullResult.pulled, + conflicts: pushResult.conflicts + pullResult.conflicts, + errors: pushResult.errors + pullResult.errors, + timestamp: new Date().toISOString(), + }; + + this.trackTelemetry('sync_complete', '*', undefined, { + pushed: combined.pushed, + pulled: combined.pulled, + conflicts: combined.conflicts, + errors: combined.errors, + }); + + return combined; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Queue Management + // ─────────────────────────────────────────────────────────────────────────── + + private async getQueue(): Promise { + const queue = await this.config.storage.getItem(QUEUE_KEY); + return queue || []; + } + + private async saveQueue(queue: SyncItem[]): Promise { + await this.config.storage.setItem(QUEUE_KEY, queue); + this.queueLength = queue.length; + } + + private async pushQueue(): Promise { + const queue = await this.getQueue(); + const result = this.emptyResult(); + + if (queue.length === 0) return result; + + this.setStatus('syncing'); + const remaining: SyncItem[] = []; + + for (const item of queue) { + try { + await this.pushItemWithRetry(item); + result.pushed++; + } catch (error) { + if (error instanceof SyncConflictError) { + result.conflicts++; + const resolved = await this.handleConflict(item, error.remoteData); + if (resolved) { + result.pushed++; + } else { + result.errors++; + } + } else if (item.retryCount < this.config.maxRetries) { + item.retryCount++; + item.lastError = error instanceof Error ? error.message : String(error); + remaining.push(item); + } else { + result.errors++; + this.trackTelemetry('sync_push_dropped', item.entity, error); + } + } + } + + await this.saveQueue(remaining); + + if (result.errors > 0) { + result.success = false; + } + + return result; + } + + private async pushItemWithRetry(item: SyncItem): Promise { + const entityConfig = this.config.entities[item.entity]; + if (!entityConfig) { + throw new Error(`Unknown entity: ${item.entity}`); + } + + const dataId = (item.data as { id?: string })?.id; + const path = + (item.operation === 'delete' || item.operation === 'update') && dataId + ? `${entityConfig.endpoint}/${dataId}` + : entityConfig.endpoint; + + const method = + item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST'; + + const headers: Record = {}; + if (method !== 'DELETE') { + headers['Content-Type'] = 'application/json'; + } + + // Attempt with exponential backoff on transient failures + let lastError: unknown; + const maxAttempts = Math.max(1, this.config.maxRetries - item.retryCount); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await this.config.apiClient.fetch(path, { + method, + headers, + body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined, + }); + // Success — track and return + this.trackTelemetry('sync_push_success', item.entity); + return; + } catch (error) { + lastError = error; + + // Check for conflict (409) — don't retry, let caller handle + if (error instanceof SyncConflictError) { + throw error; + } + if (this.isConflictError(error)) { + throw new SyncConflictError(undefined); + } + + // Non-retriable errors — throw immediately + if (this.isNonRetriable(error)) { + throw error; + } + + // Transient error — backoff and retry + if (attempt < maxAttempts - 1) { + const delay = computeBackoff( + attempt, + this.config.retryBaseDelayMs, + this.config.retryMaxDelayMs + ); + await sleep(delay); + } + } + } + + throw lastError; + } + + private async pullEntity(entityName: string, endpoint: string): Promise { + const lastSync = await this.getLastSyncTime(); + const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint; + + const response = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path); + + if (response.error || !response.data) { + return 0; + } + + const items = response.data.items ?? []; + + if (items.length > 0 && this.config.onPull) { + await this.config.onPull(entityName, items); + } + + return items.length; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Conflict Resolution + // ─────────────────────────────────────────────────────────────────────────── + + private async handleConflict(item: SyncItem, remoteData: unknown): Promise { + const entityConfig = this.config.entities[item.entity]; + if (!entityConfig) return false; + + const strategy = entityConfig.conflictStrategy; + this.trackTelemetry('sync_conflict', item.entity, undefined, { strategy }); + + try { + const winner = await this.resolveConflict(item, remoteData, strategy); + + if (winner === remoteData) { + // Server wins — nothing to push, consumer gets remote via onPull + if (this.config.onPull) { + await this.config.onPull(item.entity, [remoteData]); + } + return true; + } + + // Client data wins — re-push with force + const dataId = (winner as { id?: string })?.id; + const endpoint = entityConfig.endpoint; + const path = dataId ? `${endpoint}/${dataId}` : endpoint; + await this.config.apiClient.fetch(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(winner), + }); + return true; + } catch { + return false; + } + } + + private async resolveConflict( + item: SyncItem, + remoteData: unknown, + strategy: ConflictStrategy + ): Promise { + switch (strategy) { + case 'server-wins': + return remoteData; + + case 'client-wins': + return item.data; + + case 'last-write-wins': { + const localTime = new Date(item.timestamp).getTime(); + const remoteTime = new Date( + (remoteData as { updatedAt?: string })?.updatedAt ?? '1970-01-01' + ).getTime(); + return localTime > remoteTime ? item.data : remoteData; + } + + case 'manual': { + if (this.config.onConflict) { + const conflict: Conflict = { + entity: item.entity, + localItem: item, + remoteData, + }; + return await this.config.onConflict(conflict); + } + // No handler — fall back to server-wins + return remoteData; + } + + default: + return remoteData; + } + } + + // ─────────────────────────────────────────────────────────────────────────── + // Connectivity Detection + // ─────────────────────────────────────────────────────────────────────────── + + private setupConnectivityDetection(): void { + if (typeof globalThis === 'undefined') return; + const win = typeof window !== 'undefined' ? window : undefined; + if (!win?.addEventListener) return; + + this.onlineHandler = () => { + this.setStatus('idle'); + void this.flush(); + }; + this.offlineHandler = () => { + this.setStatus('offline'); + }; + + win.addEventListener('online', this.onlineHandler); + win.addEventListener('offline', this.offlineHandler); + } + + private isOnline(): boolean { + if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { + return navigator.onLine; + } + return true; // Assume online in non-browser environments (Node.js, SSR) + } + + async flush(): Promise { + if (this.destroyed || this.status === 'syncing') return; + const result = await this.pushQueue(); + if (result.success && result.errors === 0) { + this.setStatus('idle'); + } + } + + destroy(): void { + this.destroyed = true; + const win = typeof window !== 'undefined' ? window : undefined; + if (win) { + if (this.onlineHandler) win.removeEventListener('online', this.onlineHandler); + if (this.offlineHandler) win.removeEventListener('offline', this.offlineHandler); + } + this.statusListeners.clear(); + } + + // ─────────────────────────────────────────────────────────────────────────── + // Status & Monitoring + // ─────────────────────────────────────────────────────────────────────────── + + getQueueLength(): number { + return this.queueLength; + } + + getStatus(): SyncStatusInfo { + return { + status: this.status, + queueLength: this.queueLength, + lastSyncAt: this.lastSyncAt, + lastError: this.lastError, + }; + } + + onStatusChange(callback: SyncStatusCallback): () => void { + this.statusListeners.add(callback); + return () => this.statusListeners.delete(callback); + } + + private setStatus(status: SyncStatus, error?: string): void { + this.status = status; + if (error) this.lastError = error; + this.notifyStatus(); + } + + private notifyStatus(): void { + const info: SyncStatusInfo = { + status: this.status, + queueLength: this.queueLength, + lastSyncAt: this.lastSyncAt, + lastError: this.lastError, + }; + this.statusListeners.forEach(cb => cb(info)); + } + + // ─────────────────────────────────────────────────────────────────────────── + // Utility + // ─────────────────────────────────────────────────────────────────────────── + + async clearQueue(): Promise { + await this.saveQueue([]); + this.notifyStatus(); + } + + async reprocessFailed(): Promise { + const queue = await this.getQueue(); + const reset = queue.map(item => ({ + ...item, + retryCount: 0, + lastError: undefined, + })); + await this.saveQueue(reset); + await this.flush(); + } + + private async getLastSyncTime(): Promise { + return (await this.config.storage.getItem(LAST_SYNC_KEY)) || undefined; + } + + private async setLastSyncTime(timestamp: string): Promise { + await this.config.storage.setItem(LAST_SYNC_KEY, timestamp); + } + + private generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + } + + private getDedupKey(entity: string, data: unknown): string { + const id = (data as { id?: string })?.id; + return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`; + } + + private emptyResult(): SyncResult { + return { + success: true, + pushed: 0, + pulled: 0, + conflicts: 0, + errors: 0, + timestamp: new Date().toISOString(), + }; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Error Classification + // ─────────────────────────────────────────────────────────────────────────── + + private isConflictError(error: unknown): boolean { + if (error instanceof SyncConflictError) return true; + const msg = error instanceof Error ? error.message : String(error); + return msg.includes('409') || msg.includes('conflict'); + } + + private isNonRetriable(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + // 4xx errors (except 408, 429) are non-retriable + return /\b(400|401|403|404|405|406|410|422)\b/.test(msg); + } + + private extractRemoteData(error: unknown): unknown { + if (error instanceof SyncConflictError) return error.remoteData; + return undefined; + } + + // ─────────────────────────────────────────────────────────────────────────── + // Telemetry + // ─────────────────────────────────────────────────────────────────────────── + + private trackTelemetry( + eventName: string, + entity: string, + error?: unknown, + extra?: Record + ): void { + if (!this.config.telemetryClient) return; + try { + this.config.telemetryClient.trackEvent('sync', 'sync-engine', eventName, { + tags: { + productId: this.config.productId, + entity, + ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), + }, + metrics: extra as Record | undefined, + }); + } catch { + // Telemetry should never break sync + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Factory Function +// ───────────────────────────────────────────────────────────────────────────── + +export function createSyncEngine(config: SyncEngineConfig): SyncEngine { + return new SyncEngineImpl(config); +} diff --git a/vendor/bytelyst/sync/src/index.ts b/vendor/bytelyst/sync/src/index.ts new file mode 100644 index 0000000..e49e961 --- /dev/null +++ b/vendor/bytelyst/sync/src/index.ts @@ -0,0 +1,52 @@ +/** + * @bytelyst/sync + * + * Offline-first sync engine with configurable storage adapters and conflict resolution. + * + * @example + * ```typescript + * import { createSyncEngine, LocalStorageAdapter } from '@bytelyst/sync'; + * import { createApiClient } from '@bytelyst/api-client'; + * + * const sync = createSyncEngine({ + * productId: 'myapp', + * entities: { + * tasks: { + * endpoint: '/api/tasks', + * partitionKey: 'userId', + * conflictStrategy: 'server-wins', + * }, + * }, + * storage: new LocalStorageAdapter(), + * apiClient: createApiClient({ baseURL: 'https://api.example.com' }), + * }); + * + * // Push changes + * await sync.push('tasks', { title: 'New Task' }); + * + * // Sync with server + * const result = await sync.fullSync(); + * console.log(`Pushed: ${result.pushed}, Pulled: ${result.pulled}`); + * ``` + */ + +export { createSyncEngine, SyncEngineImpl, SyncConflictError, computeBackoff } from './engine.js'; + +export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js'; + +export type { + SyncEngine, + SyncEngineConfig, + EntityName, + EntityConfig, + ConflictStrategy, + SyncStatus, + SyncOperation, + SyncItem, + SyncResult, + SyncStatusInfo, + SyncStatusCallback, + PullHandler, + StorageAdapter, + Conflict, +} from './types.js'; diff --git a/vendor/bytelyst/sync/src/storage.ts b/vendor/bytelyst/sync/src/storage.ts new file mode 100644 index 0000000..eb902f0 --- /dev/null +++ b/vendor/bytelyst/sync/src/storage.ts @@ -0,0 +1,127 @@ +/** + * Storage Adapters + * + * @module @bytelyst/sync/storage + */ + +import type { StorageAdapter } from './types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// LocalStorage Adapter (Web) +// ───────────────────────────────────────────────────────────────────────────── + +export class LocalStorageAdapter implements StorageAdapter { + private prefix: string; + + constructor(prefix = 'bytelyst:sync:') { + this.prefix = prefix; + } + + getItem(key: string): T | null { + if (typeof localStorage === 'undefined') return null; + const value = localStorage.getItem(this.prefix + key); + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + + setItem(key: string, value: T): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(this.prefix + key, JSON.stringify(value)); + } + + removeItem(key: string): void { + if (typeof localStorage === 'undefined') return; + localStorage.removeItem(this.prefix + key); + } + + keys(): string[] { + if (typeof localStorage === 'undefined') return []; + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.prefix)) { + keys.push(key.slice(this.prefix.length)); + } + } + return keys; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// In-Memory Adapter (Testing / SSR) +// ───────────────────────────────────────────────────────────────────────────── + +export class InMemoryAdapter implements StorageAdapter { + private store = new Map(); + + getItem(key: string): T | null { + const value = this.store.get(key); + return value !== undefined ? (value as T) : null; + } + + setItem(key: string, value: T): void { + this.store.set(key, value); + } + + removeItem(key: string): void { + this.store.delete(key); + } + + keys(): string[] { + return Array.from(this.store.keys()); + } + + clear(): void { + this.store.clear(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// MMKV Adapter Interface (React Native) +// ───────────────────────────────────────────────────────────────────────────── + +export interface MMKVInstance { + getString(key: string): string | undefined; + set(key: string, value: string): void; + delete(key: string): void; + getAllKeys(): string[]; +} + +export class MMKVAdapter implements StorageAdapter { + private mmkv: MMKVInstance; + private prefix: string; + + constructor(mmkv: MMKVInstance, prefix = 'sync:') { + this.mmkv = mmkv; + this.prefix = prefix; + } + + getItem(key: string): T | null { + const value = this.mmkv.getString(this.prefix + key); + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } + } + + setItem(key: string, value: T): void { + this.mmkv.set(this.prefix + key, JSON.stringify(value)); + } + + removeItem(key: string): void { + this.mmkv.delete(this.prefix + key); + } + + keys(): string[] { + return this.mmkv + .getAllKeys() + .filter(k => k.startsWith(this.prefix)) + .map(k => k.slice(this.prefix.length)); + } +} diff --git a/vendor/bytelyst/sync/src/sync.test.ts b/vendor/bytelyst/sync/src/sync.test.ts new file mode 100644 index 0000000..ce876b0 --- /dev/null +++ b/vendor/bytelyst/sync/src/sync.test.ts @@ -0,0 +1,608 @@ +/** + * Sync Engine Tests — 25+ tests + * + * Covers: queue persistence, retry with backoff, conflict resolution (all 4 + * strategies), deduplication, connectivity, onPull callback, telemetry, + * delete consolidation, multiple entities, status monitoring, destroy. + * + * @module @bytelyst/sync/sync.test + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js'; +import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js'; +import type { ApiClient, ApiResult } from '@bytelyst/api-client'; +import type { TelemetryClient } from '@bytelyst/telemetry-client'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +interface MockApiClient extends ApiClient { + getRequests(): { path: string; options?: RequestInit }[]; + setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void; + setSafeFetchBehavior(fn: (path: string) => unknown): void; +} + +function createMockApiClient(): MockApiClient { + const requests: { path: string; options?: RequestInit }[] = []; + let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null; + let safeFetchBehavior: ((path: string) => unknown) | null = null; + + return { + fetch: async (path: string, options?: RequestInit): Promise => { + requests.push({ path, options }); + if (fetchBehavior) return fetchBehavior(path, options) as T; + return {} as T; + }, + safeFetch: async (path: string, options?: RequestInit): Promise> => { + requests.push({ path, options }); + if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult; + return { data: { items: [] } as unknown as T, error: null }; + }, + getRequests: () => requests, + setFetchBehavior: fn => { + fetchBehavior = fn; + }, + setSafeFetchBehavior: fn => { + safeFetchBehavior = fn; + }, + }; +} + +function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } { + const events: { eventName: string }[] = []; + return { + init: vi.fn(), + trackEvent: (eventType: string, module: string, eventName: string) => { + events.push({ eventName }); + }, + flush: vi.fn(), + shutdown: vi.fn(), + getInstallId: () => 'test-install', + getSessionId: () => 'test-session', + events, + }; +} + +const TASKS_ENTITY: EntityConfig = { + endpoint: '/tasks', + partitionKey: 'userId', + conflictStrategy: 'server-wins', +}; + +function makeConfig( + storage: InMemoryAdapter, + apiClient: MockApiClient, + overrides?: Partial +): SyncEngineConfig { + return { + productId: 'test', + entities: { tasks: TASKS_ENTITY }, + storage, + apiClient, + maxRetries: 3, + retryBaseDelayMs: 1, // fast for tests + retryMaxDelayMs: 10, + ...overrides, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Storage Adapter Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('InMemoryAdapter', () => { + it('stores and retrieves items', () => { + const s = new InMemoryAdapter(); + s.setItem('k', { x: 1 }); + expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 }); + }); + + it('returns null for missing keys', () => { + expect(new InMemoryAdapter().getItem('nope')).toBeNull(); + }); + + it('lists all keys', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.setItem('b', 2); + expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('removes items', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.removeItem('a'); + expect(s.getItem('a')).toBeNull(); + }); + + it('clears all items', () => { + const s = new InMemoryAdapter(); + s.setItem('a', 1); + s.setItem('b', 2); + s.clear(); + expect(s.keys()).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// computeBackoff +// ───────────────────────────────────────────────────────────────────────────── + +describe('computeBackoff', () => { + it('returns increasing delays', () => { + const d0 = computeBackoff(0, 1000, 30000); + const d1 = computeBackoff(1, 1000, 30000); + const d2 = computeBackoff(2, 1000, 30000); + // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter) + expect(d0).toBeLessThan(d1); + expect(d1).toBeLessThan(d2); + }); + + it('caps at maxMs', () => { + const d = computeBackoff(20, 1000, 5000); + expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Sync Engine — Core Operations +// ───────────────────────────────────────────────────────────────────────────── + +describe('Sync Engine', () => { + let storage: InMemoryAdapter; + let apiClient: MockApiClient; + + beforeEach(() => { + storage = new InMemoryAdapter(); + apiClient = createMockApiClient(); + }); + + // ─── Creation ────────────────────────────────────────────────────────── + + it('creates engine with all interface methods', () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + expect(engine.push).toBeTypeOf('function'); + expect(engine.delete).toBeTypeOf('function'); + expect(engine.pull).toBeTypeOf('function'); + expect(engine.fullSync).toBeTypeOf('function'); + expect(engine.getQueueLength).toBeTypeOf('function'); + expect(engine.getStatus).toBeTypeOf('function'); + expect(engine.onStatusChange).toBeTypeOf('function'); + expect(engine.clearQueue).toBeTypeOf('function'); + expect(engine.reprocessFailed).toBeTypeOf('function'); + expect(engine.flush).toBeTypeOf('function'); + expect(engine.destroy).toBeTypeOf('function'); + }); + + // ─── Queue Persistence ───────────────────────────────────────────────── + + it('persists queue across engine instances (simulated restart)', async () => { + const engine1 = createSyncEngine(makeConfig(storage, apiClient)); + await engine1.push('tasks', { id: 't1', title: 'persist me' }); + engine1.destroy(); + + // "Restart" — new engine, same storage + const engine2 = createSyncEngine(makeConfig(storage, apiClient)); + const result = await engine2.fullSync(); + expect(result.pushed).toBe(1); + + const reqs = apiClient.getRequests(); + const postReq = reqs.find(r => r.options?.method === 'POST'); + expect(postReq).toBeDefined(); + expect(postReq!.path).toBe('/tasks'); + }); + + // ─── Push & Deduplication ────────────────────────────────────────────── + + it('deduplicates updates to same entity+id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: '1', title: 'v1' }, 'update'); + await engine.push('tasks', { id: '1', title: 'v2' }, 'update'); + expect(engine.getQueueLength()).toBe(1); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + + // The last value should be sent + const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH'); + expect(patchReq).toBeDefined(); + expect(patchReq!.path).toBe('/tasks/1'); + const body = JSON.parse(patchReq!.options!.body as string); + expect(body.title).toBe('v2'); + }); + + it('does not deduplicate different operations on same id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: '1', title: 'create' }, 'create'); + await engine.push('tasks', { id: '1', title: 'update' }, 'update'); + expect(engine.getQueueLength()).toBe(2); + }); + + it('does not deduplicate items without id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'Task A' }); + await engine.push('tasks', { title: 'Task B' }); + expect(engine.getQueueLength()).toBe(2); + }); + + // ─── Delete ──────────────────────────────────────────────────────────── + + it('delete removes pending create/update for same entity+id', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: 'x', title: 'created' }); + await engine.push('tasks', { id: 'x', title: 'updated' }, 'update'); + // Now delete should collapse the above + await engine.delete('tasks', 'x'); + expect(engine.getQueueLength()).toBe(1); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE'); + expect(delReq).toBeDefined(); + expect(delReq!.path).toBe('/tasks/x'); + }); + + // ─── Pull + onPull Callback ──────────────────────────────────────────── + + it('invokes onPull with pulled items', async () => { + const pulled: { entity: string; items: unknown[] }[] = []; + apiClient.setSafeFetchBehavior(() => ({ + data: { items: [{ id: 'r1', title: 'Remote Task' }] }, + error: null, + })); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + onPull: (entity, items) => { + pulled.push({ entity, items }); + }, + }) + ); + + const result = await engine.pull(); + expect(result.pulled).toBe(1); + expect(pulled).toHaveLength(1); + expect(pulled[0].entity).toBe('tasks'); + expect(pulled[0].items).toHaveLength(1); + }); + + it('pull appends ?since= parameter after first sync', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + + await engine.pull(); // first pull — no since + const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks')); + expect(firstReq!.path).toBe('/tasks'); + + await engine.pull(); // second pull — should have since= + const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks')); + const secondReq = allReqs[allReqs.length - 1]; + expect(secondReq.path).toContain('?since='); + }); + + // ─── fullSync ────────────────────────────────────────────────────────── + + it('fullSync pushes then pulls', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { id: 't1', title: 'local' }); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(1); + expect(engine.getQueueLength()).toBe(0); + expect(engine.getStatus().lastSyncAt).toBeTruthy(); + }); + + // ─── Retry with Backoff ──────────────────────────────────────────────── + + it('retries on transient errors and keeps item in queue', async () => { + let callCount = 0; + apiClient.setFetchBehavior(() => { + callCount++; + throw new Error('500 Internal Server Error'); + }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 })); + await engine.push('tasks', { id: 'fail', title: 'will fail' }); + + await engine.flush(); + + // Item should still be in queue with incremented retryCount + expect(engine.getQueueLength()).toBe(1); + // Multiple fetch attempts were made (backoff retries within pushItemWithRetry) + expect(callCount).toBeGreaterThan(1); + }); + + it('drops item after exceeding maxRetries', async () => { + apiClient.setFetchBehavior(() => { + throw new Error('500'); + }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); + await engine.push('tasks', { id: 'drop', title: 'drop me' }); + + // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount + await engine.flush(); + // Second flush: retryCount >= maxRetries → dropped + await engine.flush(); + + expect(engine.getQueueLength()).toBe(0); + }); + + // ─── Conflict Resolution ─────────────────────────────────────────────── + + it('server-wins: accepts remote data on conflict', async () => { + const pulled: unknown[][] = []; + apiClient.setFetchBehavior(() => { + throw new SyncConflictError({ id: 'c1', title: 'Server Version' }); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, + }, + onPull: (_entity, items) => { + pulled.push(items); + }, + }) + ); + + await engine.push('tasks', { id: 'c1', title: 'Client Version' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + // server-wins: onPull should have been called with remote data + expect(pulled.length).toBeGreaterThanOrEqual(1); + }); + + it('client-wins: re-pushes local data on conflict', async () => { + let callIdx = 0; + apiClient.setFetchBehavior(() => { + callIdx++; + if (callIdx === 1) { + throw new SyncConflictError({ id: 'c2', title: 'Server' }); + } + return {}; // Second call (PUT) succeeds + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' }, + }, + }) + ); + + await engine.push('tasks', { id: 'c2', title: 'Client' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + expect(result.pushed).toBe(1); // conflict resolved → counted as pushed + // Should have made a PUT request with client data + const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); + expect(putReq).toBeDefined(); + }); + + it('last-write-wins: picks newer timestamp', async () => { + const pulled: unknown[][] = []; + const oldDate = '2020-01-01T00:00:00.000Z'; + apiClient.setFetchBehavior(() => { + throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate }); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { + endpoint: '/tasks', + partitionKey: 'userId', + conflictStrategy: 'last-write-wins', + }, + }, + onPull: (_entity, items) => { + pulled.push(items); + }, + }) + ); + + // Client push will have a newer timestamp than 2020 + await engine.push('tasks', { id: 'c3', title: 'Client Newer' }); + const result = await engine.fullSync(); + expect(result.conflicts).toBe(1); + // Client is newer → should NOT have called onPull with server data + // Instead it should have re-pushed (PUT) + const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); + expect(putReq).toBeDefined(); + }); + + it('manual: calls onConflict handler', async () => { + apiClient.setFetchBehavior((_path, options) => { + const method = options?.method; + if (method === 'POST') { + throw new SyncConflictError({ id: 'c4', title: 'Server' }); + } + return {}; + }); + + const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' }, + }, + onConflict, + }) + ); + + await engine.push('tasks', { id: 'c4', title: 'Client' }); + const result = await engine.fullSync(); + + expect(result.conflicts).toBe(1); + expect(onConflict).toHaveBeenCalledTimes(1); + expect(onConflict.mock.calls[0][0]).toMatchObject({ + entity: 'tasks', + remoteData: { id: 'c4', title: 'Server' }, + }); + }); + + // ─── Multiple Entities ───────────────────────────────────────────────── + + it('handles multiple entity types', async () => { + const pulled: { entity: string; items: unknown[] }[] = []; + apiClient.setSafeFetchBehavior(path => { + if (path.startsWith('/tasks')) { + return { data: { items: [{ id: 't1' }] }, error: null }; + } + if (path.startsWith('/notes')) { + return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null }; + } + return { data: { items: [] }, error: null }; + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + entities: { + tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, + notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' }, + }, + onPull: (entity, items) => { + pulled.push({ entity, items }); + }, + }) + ); + + await engine.push('tasks', { id: 't-new', title: 'Task' }); + await engine.push('notes', { id: 'n-new', body: 'Note' }); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(2); + expect(result.pulled).toBe(3); // 1 task + 2 notes + expect(pulled).toHaveLength(2); + }); + + // ─── Status Monitoring ───────────────────────────────────────────────── + + it('returns correct initial status', () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const status = engine.getStatus(); + expect(status.status).toBe('idle'); + expect(status.queueLength).toBe(0); + expect(status.lastSyncAt).toBeUndefined(); + }); + + it('notifies listeners on status changes', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const statuses: SyncStatusInfo[] = []; + engine.onStatusChange(s => statuses.push({ ...s })); + + await engine.push('tasks', { id: 'x', title: 'X' }); + await engine.fullSync(); + + const statusNames = statuses.map(s => s.status); + expect(statusNames).toContain('syncing'); + expect(statusNames).toContain('idle'); + }); + + it('unsubscribe stops notifications', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + const statuses: string[] = []; + const unsub = engine.onStatusChange(s => statuses.push(s.status)); + + await engine.push('tasks', { title: 'A' }); + const countBefore = statuses.length; + + unsub(); + await engine.push('tasks', { title: 'B' }); + expect(statuses.length).toBe(countBefore); + }); + + // ─── clearQueue ──────────────────────────────────────────────────────── + + it('clearQueue empties the queue', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'A' }); + await engine.push('tasks', { title: 'B' }); + expect(engine.getQueueLength()).toBe(2); + + await engine.clearQueue(); + expect(engine.getQueueLength()).toBe(0); + + const result = await engine.fullSync(); + expect(result.pushed).toBe(0); + }); + + // ─── reprocessFailed ─────────────────────────────────────────────────── + + it('reprocessFailed resets retry counts and re-flushes', async () => { + let failCount = 0; + apiClient.setFetchBehavior(() => { + failCount++; + if (failCount <= 2) throw new Error('transient'); + return {}; + }); + + const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); + await engine.push('tasks', { id: 'rp', title: 'reprocess' }); + await engine.flush(); // fails, item stays in queue + + expect(engine.getQueueLength()).toBe(1); + + // Now make API succeed and reprocess + apiClient.setFetchBehavior(() => ({})); + await engine.reprocessFailed(); + expect(engine.getQueueLength()).toBe(0); + }); + + // ─── Telemetry Integration ───────────────────────────────────────────── + + it('tracks sync events via telemetry client', async () => { + const telemetry = createMockTelemetry(); + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + telemetryClient: telemetry, + }) + ); + + await engine.push('tasks', { id: 't1', title: 'test' }); + await engine.fullSync(); + + const eventNames = telemetry.events.map(e => e.eventName); + expect(eventNames).toContain('sync_push_success'); + expect(eventNames).toContain('sync_complete'); + }); + + it('telemetry tracks push errors', async () => { + const telemetry = createMockTelemetry(); + apiClient.setFetchBehavior(() => { + throw new Error('400 Bad Request'); + }); + + const engine = createSyncEngine( + makeConfig(storage, apiClient, { + telemetryClient: telemetry, + maxRetries: 1, + }) + ); + + await engine.push('tasks', { id: 'bad', title: 'fail' }); + await engine.flush(); + await engine.flush(); // second flush drops item + + const eventNames = telemetry.events.map(e => e.eventName); + expect(eventNames).toContain('sync_push_dropped'); + }); + + // ─── Destroy ─────────────────────────────────────────────────────────── + + it('destroy prevents further flush', async () => { + const engine = createSyncEngine(makeConfig(storage, apiClient)); + await engine.push('tasks', { title: 'orphan' }); + engine.destroy(); + + await engine.flush(); // should be no-op after destroy + // Item still in queue (flush was no-op) + expect(engine.getQueueLength()).toBe(1); + }); +}); diff --git a/vendor/bytelyst/sync/src/types.ts b/vendor/bytelyst/sync/src/types.ts new file mode 100644 index 0000000..e8fc60d --- /dev/null +++ b/vendor/bytelyst/sync/src/types.ts @@ -0,0 +1,129 @@ +/** + * Sync Engine Types + * + * @module @bytelyst/sync/types + */ + +import type { ApiClient } from '@bytelyst/api-client'; +import type { TelemetryClient } from '@bytelyst/telemetry-client'; + +// ───────────────────────────────────────────────────────────────────────────── +// Core Types +// ───────────────────────────────────────────────────────────────────────────── + +export type EntityName = string; + +export type ConflictStrategy = 'server-wins' | 'client-wins' | 'last-write-wins' | 'manual'; + +export type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error'; + +export type SyncOperation = 'create' | 'update' | 'delete'; + +export interface EntityConfig { + /** REST endpoint path, e.g. '/api/timers' */ + endpoint: string; + /** Cosmos partition key field name (for reference) */ + partitionKey: string; + /** Conflict resolution strategy */ + conflictStrategy: ConflictStrategy; +} + +/** + * Callback invoked when items are pulled from the server. + * Consumer is responsible for merging pulled data into their local store. + */ +export type PullHandler = (entity: EntityName, items: unknown[]) => void | Promise; + +export interface SyncEngineConfig { + productId: string; + entities: Record; + storage: StorageAdapter; + apiClient: ApiClient; + telemetryClient?: TelemetryClient; + /** Called when items are pulled from server — consumer merges into local store */ + onPull?: PullHandler; + /** Called for 'manual' conflict strategy. Return the winning data. */ + onConflict?: (conflict: Conflict) => Promise | unknown; + /** Max retry attempts before dropping an item. Default: 5. */ + maxRetries?: number; + /** Base delay in ms for exponential backoff. Default: 1000. */ + retryBaseDelayMs?: number; + /** Maximum backoff delay in ms. Default: 30000. */ + retryMaxDelayMs?: number; +} + +export interface SyncItem { + id: string; + entity: EntityName; + operation: SyncOperation; + data: unknown; + timestamp: string; + retryCount: number; + lastError?: string; +} + +export interface SyncResult { + success: boolean; + pushed: number; + pulled: number; + conflicts: number; + errors: number; + timestamp: string; +} + +export interface SyncStatusInfo { + status: SyncStatus; + queueLength: number; + lastSyncAt?: string; + lastError?: string; +} + +export type SyncStatusCallback = (status: SyncStatusInfo) => void; + +export interface Conflict { + entity: EntityName; + localItem: SyncItem; + remoteData: unknown; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Storage Adapter Interface +// ───────────────────────────────────────────────────────────────────────────── + +export interface StorageAdapter { + getItem(key: string): Promise | T | null; + setItem(key: string, value: T): Promise | void; + removeItem(key: string): Promise | void; + keys(): Promise | string[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sync Engine Interface +// ───────────────────────────────────────────────────────────────────────────── + +export interface SyncEngine { + /** Queue a create/update for later push. Deduplicates by entity + data.id. */ + push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise; + /** Queue a delete for later push. */ + delete(entity: EntityName, id: string): Promise; + /** Pull remote changes for all entities. Invokes onPull callback. */ + pull(): Promise; + /** Push queued items, then pull remote changes. */ + fullSync(): Promise; + + /** Current number of items in the offline queue. */ + getQueueLength(): number; + /** Current sync status snapshot. */ + getStatus(): SyncStatusInfo; + /** Subscribe to status changes. Returns unsubscribe function. */ + onStatusChange(callback: SyncStatusCallback): () => void; + + /** Remove all items from the offline queue. */ + clearQueue(): Promise; + /** Reset retry counts on all failed items and re-flush. */ + reprocessFailed(): Promise; + /** Manually trigger a flush of the push queue. */ + flush(): Promise; + /** Tear down connectivity listeners. */ + destroy(): void; +} diff --git a/vendor/bytelyst/sync/tsconfig.json b/vendor/bytelyst/sync/tsconfig.json new file mode 100644 index 0000000..2118468 --- /dev/null +++ b/vendor/bytelyst/sync/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/sync/vitest.config.ts b/vendor/bytelyst/sync/vitest.config.ts new file mode 100644 index 0000000..5e0718b --- /dev/null +++ b/vendor/bytelyst/sync/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + pool: 'forks', + include: ['src/**/*.test.ts'], + coverage: { + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/index.ts'], + }, + }, +}); diff --git a/vendor/bytelyst/testing/package.json b/vendor/bytelyst/testing/package.json new file mode 100644 index 0000000..0e819bc --- /dev/null +++ b/vendor/bytelyst/testing/package.json @@ -0,0 +1,37 @@ +{ + "name": "@bytelyst/testing", + "version": "0.1.5", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "pretest": "pnpm --dir ../.. --filter @bytelyst/fastify-core build", + "build": "tsc", + "test": "vitest run --pool forks" + }, + "devDependencies": { + "@bytelyst/fastify-core": "workspace:*", + "fastify": "^5.2.1" + }, + "peerDependencies": { + "vitest": ">=3.0.0", + "fastify": ">=5.0.0" + }, + "peerDependenciesMeta": { + "fastify": { + "optional": true + } + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/testing/src/__tests__/testing.test.ts b/vendor/bytelyst/testing/src/__tests__/testing.test.ts new file mode 100644 index 0000000..4112315 --- /dev/null +++ b/vendor/bytelyst/testing/src/__tests__/testing.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + createCosmosMocks, + TEST_JWT_SECRET, + TEST_USERS, + createTestTokenPayload, + injectGet, + expectHealthOk, +} from '../index.js'; +import { createServiceApp } from '@bytelyst/fastify-core'; + +describe('createCosmosMocks', () => { + it('returns all mock objects', () => { + const mocks = createCosmosMocks(); + expect(mocks.mockContainer).toBeDefined(); + expect(mocks.mockContainer.id).toBe('test-container'); + expect(mocks.mockDatabase).toBeDefined(); + expect(mocks.mockDatabases).toBeDefined(); + expect(mocks.MockCosmosClient).toBeDefined(); + expect(typeof mocks.resetMocks).toBe('function'); + }); + + it('MockCosmosClient returns database', () => { + const { MockCosmosClient, mockDatabase } = createCosmosMocks(); + const client = new MockCosmosClient('endpoint', 'key'); + expect(client.database('test-db')).toBe(mockDatabase); + }); + + it('mockContainer.items has CRUD methods', () => { + const { mockContainer } = createCosmosMocks(); + expect(typeof mockContainer.items.create).toBe('function'); + expect(typeof mockContainer.items.query).toBe('function'); + expect(typeof mockContainer.items.upsert).toBe('function'); + expect(typeof mockContainer.item).toBe('function'); + }); + + it('resetMocks clears all mock state', () => { + const { mockContainer, resetMocks } = createCosmosMocks(); + mockContainer.items.create({ id: '1' }); + expect(mockContainer.items.create).toHaveBeenCalledOnce(); + resetMocks(); + expect(mockContainer.items.create).not.toHaveBeenCalled(); + }); +}); + +describe('auth fixtures', () => { + it('TEST_JWT_SECRET is a non-empty string', () => { + expect(TEST_JWT_SECRET).toBeTruthy(); + expect(TEST_JWT_SECRET.length).toBeGreaterThanOrEqual(16); + }); + + it('TEST_USERS has admin, viewer, and user', () => { + expect(TEST_USERS.admin.email).toBe('admin@test.com'); + expect(TEST_USERS.viewer.role).toBe('viewer'); + expect(TEST_USERS.user.productId).toBe('lysnrai'); + }); + + it('createTestTokenPayload returns valid shape', () => { + const payload = createTestTokenPayload('admin'); + expect(payload.sub).toBe('user-admin-001'); + expect(payload.email).toBe('admin@test.com'); + expect(payload.role).toBe('super_admin'); + expect(payload.iss).toBe('test'); + expect(payload.exp).toBeGreaterThan(payload.iat); + }); + + it('createTestTokenPayload defaults to admin', () => { + const payload = createTestTokenPayload(); + expect(payload.sub).toBe('user-admin-001'); + }); +}); + +describe('fastify helpers', () => { + it('injectGet + expectHealthOk work with a real fastify-core app', async () => { + const app = await createServiceApp({ + name: 'test-svc', + version: '1.0.0', + logger: false, + }); + + const res = await injectGet(app, '/health'); + expect(res.statusCode).toBe(200); + expectHealthOk(res, 'test-svc'); + + await app.close(); + }); + + it('expectHealthOk throws on wrong service name', async () => { + const app = await createServiceApp({ + name: 'real-svc', + version: '1.0.0', + logger: false, + }); + + const res = await injectGet(app, '/health'); + expect(() => expectHealthOk(res, 'wrong-name')).toThrow('Expected service "wrong-name"'); + + await app.close(); + }); +}); diff --git a/vendor/bytelyst/testing/src/auth-fixtures.ts b/vendor/bytelyst/testing/src/auth-fixtures.ts new file mode 100644 index 0000000..aaa94ad --- /dev/null +++ b/vendor/bytelyst/testing/src/auth-fixtures.ts @@ -0,0 +1,55 @@ +/** + * Auth test fixtures — JWT tokens, user payloads, and password helpers. + * + * Usage: + * ```ts + * import { TEST_USERS, TEST_JWT_SECRET, createTestToken } from '@bytelyst/testing'; + * ``` + */ + +/** Test JWT secret — NEVER use in production */ +export const TEST_JWT_SECRET = 'test-jwt-secret-32-chars-long!!'; + +/** Pre-built test user payloads */ +export const TEST_USERS = { + admin: { + id: 'user-admin-001', + email: 'admin@test.com', + name: 'Test Admin', + role: 'super_admin', + productId: 'lysnrai', + }, + viewer: { + id: 'user-viewer-001', + email: 'viewer@test.com', + name: 'Test Viewer', + role: 'viewer', + productId: 'lysnrai', + }, + user: { + id: 'user-basic-001', + email: 'user@test.com', + name: 'Test User', + role: 'user', + productId: 'lysnrai', + }, +} as const; + +export type TestUserKey = keyof typeof TEST_USERS; + +/** + * Create a minimal JWT-like token payload for testing (not cryptographically signed). + * For actual JWT creation, use `@bytelyst/auth` createJwtUtils(). + */ +export function createTestTokenPayload(userKey: TestUserKey = 'admin') { + const user = TEST_USERS[userKey]; + return { + sub: user.id, + email: user.email, + role: user.role, + productId: user.productId, + iss: 'test', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; +} diff --git a/vendor/bytelyst/testing/src/cosmos-mocks.ts b/vendor/bytelyst/testing/src/cosmos-mocks.ts new file mode 100644 index 0000000..86dd3f3 --- /dev/null +++ b/vendor/bytelyst/testing/src/cosmos-mocks.ts @@ -0,0 +1,87 @@ +/** + * Shared Cosmos DB mock factories for Vitest. + * + * Usage: + * ```ts + * import { createCosmosMocks } from '@bytelyst/testing'; + * const { mockContainer, mockDatabase, MockCosmosClient, resetMocks } = createCosmosMocks(); + * vi.mock('@azure/cosmos', () => ({ CosmosClient: MockCosmosClient })); + * ``` + */ + +import { vi } from 'vitest'; + +export interface MockItem { + id: string; + [key: string]: unknown; +} + +export interface CosmosMocks { + mockContainer: { + id: string; + items: { + create: ReturnType; + query: ReturnType; + upsert: ReturnType; + }; + item: ReturnType; + }; + mockDatabase: { + container: ReturnType; + containers: { createIfNotExists: ReturnType }; + }; + mockDatabases: { + createIfNotExists: ReturnType; + }; + MockCosmosClient: ReturnType; + resetMocks: () => void; +} + +/** + * Create a full set of Cosmos DB mocks for unit testing. + * + * Returns mock objects for container, database, and the CosmosClient constructor, + * plus a `resetMocks()` function to clear all mock state between tests. + */ +export function createCosmosMocks(): CosmosMocks { + const mockItem = { + read: vi.fn().mockResolvedValue({ resource: undefined }), + replace: vi.fn().mockResolvedValue({ resource: undefined }), + delete: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({ resource: undefined }), + }; + + const mockContainer = { + id: 'test-container', + items: { + create: vi.fn().mockResolvedValue({ resource: {} }), + query: vi.fn().mockReturnValue({ + fetchAll: vi.fn().mockResolvedValue({ resources: [] }), + }), + upsert: vi.fn().mockResolvedValue({ resource: {} }), + }, + item: vi.fn().mockReturnValue(mockItem), + }; + + const mockDatabase = { + container: vi.fn().mockReturnValue(mockContainer), + containers: { + createIfNotExists: vi.fn().mockResolvedValue({ container: mockContainer }), + }, + }; + + const mockDatabases = { + createIfNotExists: vi.fn().mockResolvedValue({ database: mockDatabase }), + }; + + const MockCosmosClient = vi.fn().mockImplementation(() => ({ + database: vi.fn().mockReturnValue(mockDatabase), + databases: mockDatabases, + })); + + function resetMocks() { + vi.clearAllMocks(); + } + + return { mockContainer, mockDatabase, mockDatabases, MockCosmosClient, resetMocks }; +} diff --git a/vendor/bytelyst/testing/src/fastify-helpers.ts b/vendor/bytelyst/testing/src/fastify-helpers.ts new file mode 100644 index 0000000..6673e55 --- /dev/null +++ b/vendor/bytelyst/testing/src/fastify-helpers.ts @@ -0,0 +1,80 @@ +/** + * Fastify test helpers — inject wrappers and assertion utilities. + * + * Usage: + * ```ts + * import { injectGet, injectPost, expectHealthOk } from '@bytelyst/testing'; + * const res = await injectGet(app, '/health'); + * expectHealthOk(res, 'platform-service'); + * ``` + */ + +import type { FastifyInstance } from 'fastify'; + +interface InjectResult { + statusCode: number; + payload: string; + headers: Record; + json: () => unknown; +} + +/** Inject a GET request into a Fastify instance */ +export async function injectGet( + app: FastifyInstance, + url: string, + headers?: Record +): Promise { + return app.inject({ method: 'GET', url, headers }) as unknown as Promise; +} + +/** Inject a POST request with JSON body into a Fastify instance */ +export async function injectPost( + app: FastifyInstance, + url: string, + body: unknown, + headers?: Record +): Promise { + return app.inject({ + method: 'POST', + url, + payload: body as string, + headers: { 'content-type': 'application/json', ...headers }, + }) as unknown as Promise; +} + +/** Inject a PATCH request with JSON body into a Fastify instance */ +export async function injectPatch( + app: FastifyInstance, + url: string, + body: unknown, + headers?: Record +): Promise { + return app.inject({ + method: 'PATCH', + url, + payload: body as string, + headers: { 'content-type': 'application/json', ...headers }, + }) as unknown as Promise; +} + +/** Inject a DELETE request into a Fastify instance */ +export async function injectDelete( + app: FastifyInstance, + url: string, + headers?: Record +): Promise { + return app.inject({ method: 'DELETE', url, headers }) as unknown as Promise; +} + +/** Assert a health check response has the correct shape */ +export function expectHealthOk(res: InjectResult, serviceName: string): void { + const body = JSON.parse(res.payload); + if (res.statusCode !== 200) throw new Error(`Expected 200, got ${res.statusCode}`); + if (body.status !== 'ok') throw new Error(`Expected status "ok", got "${body.status}"`); + if (body.service !== serviceName) + throw new Error(`Expected service "${serviceName}", got "${body.service}"`); + if (!body.timestamp) throw new Error('Missing timestamp in health response'); + if (!body.requestId) throw new Error('Missing requestId in health response'); +} + +export type { InjectResult }; diff --git a/vendor/bytelyst/testing/src/index.ts b/vendor/bytelyst/testing/src/index.ts new file mode 100644 index 0000000..d7fcb05 --- /dev/null +++ b/vendor/bytelyst/testing/src/index.ts @@ -0,0 +1,15 @@ +export { createCosmosMocks, type CosmosMocks, type MockItem } from './cosmos-mocks.js'; +export { + TEST_JWT_SECRET, + TEST_USERS, + createTestTokenPayload, + type TestUserKey, +} from './auth-fixtures.js'; +export { + injectGet, + injectPost, + injectPatch, + injectDelete, + expectHealthOk, + type InjectResult, +} from './fastify-helpers.js'; diff --git a/vendor/bytelyst/testing/tsconfig.json b/vendor/bytelyst/testing/tsconfig.json new file mode 100644 index 0000000..c17685d --- /dev/null +++ b/vendor/bytelyst/testing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/time-references/package.json b/vendor/bytelyst/time-references/package.json new file mode 100644 index 0000000..78f132a --- /dev/null +++ b/vendor/bytelyst/time-references/package.json @@ -0,0 +1,19 @@ +{ + "name": "@bytelyst/time-references", + "version": "0.1.5", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/vendor/bytelyst/time-references/src/client.test.ts b/vendor/bytelyst/time-references/src/client.test.ts new file mode 100644 index 0000000..db45379 --- /dev/null +++ b/vendor/bytelyst/time-references/src/client.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + getTimeReference, + getEpisodeComparison, + getEncouragingMessage, + registerReferences, + clearCustomReferences, +} from './client.js'; + +describe('getTimeReference', () => { + it('should return a reference for short duration', () => { + const ref = getTimeReference(0.1); + expect(ref.text.length).toBeGreaterThan(0); + expect(ref.emoji.length).toBeGreaterThan(0); + expect(['media', 'activity', 'travel', 'nature']).toContain(ref.category); + }); + + it('should return a reference for medium duration', () => { + const ref = getTimeReference(1.5); + expect(ref.text.length).toBeGreaterThan(0); + }); + + it('should return a reference for long duration', () => { + const ref = getTimeReference(16); + expect(ref.text.length).toBeGreaterThan(0); + }); + + it('should return a reference for very long duration', () => { + const ref = getTimeReference(72); + expect(ref.text.length).toBeGreaterThan(0); + }); + + it('should handle zero hours', () => { + const ref = getTimeReference(0); + expect(ref.text.length).toBeGreaterThan(0); + }); + + it('should handle negative as zero', () => { + const ref = getTimeReference(-5); + expect(ref.text.length).toBeGreaterThan(0); + }); +}); + +describe('getEpisodeComparison', () => { + it('should return episode count for default show', () => { + const result = getEpisodeComparison(1); + expect(result).toContain('The Office'); + expect(result).toContain('episodes'); + }); + + it('should return custom show name', () => { + const result = getEpisodeComparison(2, 'Friends', 25); + expect(result).toContain('Friends'); + }); + + it('should handle less than one episode', () => { + const result = getEpisodeComparison(0.1); + expect(result).toContain('Less than one episode'); + }); + + it('should handle exactly one episode', () => { + const result = getEpisodeComparison(22 / 60); + expect(result).toContain('1 episode'); + }); +}); + +describe('getEncouragingMessage', () => { + it('should return message for each time bracket', () => { + expect(getEncouragingMessage(0.5).length).toBeGreaterThan(0); + expect(getEncouragingMessage(2).length).toBeGreaterThan(0); + expect(getEncouragingMessage(6).length).toBeGreaterThan(0); + expect(getEncouragingMessage(12).length).toBeGreaterThan(0); + expect(getEncouragingMessage(20).length).toBeGreaterThan(0); + expect(getEncouragingMessage(30).length).toBeGreaterThan(0); + }); +}); + +describe('registerReferences', () => { + afterEach(() => { + clearCustomReferences(); + }); + + it('should allow custom references', () => { + registerReferences([ + { + minHours: 0, + maxHours: 0.1, + references: [{ text: 'Custom micro reference', emoji: '⚡', category: 'activity' }], + }, + ]); + const ref = getTimeReference(0.05); + expect(ref.text).toBe('Custom micro reference'); + }); + + it('should clear custom references', () => { + registerReferences([ + { + minHours: 0, + maxHours: 0.1, + references: [{ text: 'Will be cleared', emoji: '🧹', category: 'activity' }], + }, + ]); + clearCustomReferences(); + const ref = getTimeReference(0.05); + expect(ref.text).not.toBe('Will be cleared'); + }); +}); diff --git a/vendor/bytelyst/time-references/src/client.ts b/vendor/bytelyst/time-references/src/client.ts new file mode 100644 index 0000000..af153b7 --- /dev/null +++ b/vendor/bytelyst/time-references/src/client.ts @@ -0,0 +1,159 @@ +/** + * Familiar duration references for time-blindness aids. + * + * "About as long as a movie", "3 episodes of The Office". + * Pure client-side TS — no backend dependency. + */ + +import type { TimeRangeEntry, TimeReference } from './types.js'; + +const DEFAULT_DATABASE: TimeRangeEntry[] = [ + { + minHours: 0, + maxHours: 0.25, + references: [ + { text: 'About as long as a coffee break', emoji: '☕', category: 'activity' }, + { text: 'Like listening to a few songs', emoji: '🎵', category: 'media' }, + ], + }, + { + minHours: 0.25, + maxHours: 0.5, + references: [ + { text: 'About as long as a TV episode', emoji: '📺', category: 'media' }, + { text: 'Like a short walk around the block', emoji: '🚶', category: 'activity' }, + ], + }, + { + minHours: 0.5, + maxHours: 1, + references: [ + { text: 'About as long as a yoga class', emoji: '🧘', category: 'activity' }, + { text: 'Like watching 2 episodes of The Office', emoji: '📺', category: 'media' }, + ], + }, + { + minHours: 1, + maxHours: 2, + references: [ + { text: 'About as long as a movie', emoji: '🎬', category: 'media' }, + { text: 'Like a nice bike ride', emoji: '🚴', category: 'activity' }, + ], + }, + { + minHours: 2, + maxHours: 4, + references: [ + { text: 'About as long as a road trip to the next city', emoji: '🚗', category: 'travel' }, + { text: 'Like binge-watching a short series', emoji: '📺', category: 'media' }, + ], + }, + { + minHours: 4, + maxHours: 8, + references: [ + { text: 'About as long as a work day', emoji: '💼', category: 'activity' }, + { text: 'Like a full night of sleep', emoji: '😴', category: 'nature' }, + ], + }, + { + minHours: 8, + maxHours: 12, + references: [ + { text: 'About as long as a cross-country flight', emoji: '✈️', category: 'travel' }, + { text: 'Like sunrise to sunset in winter', emoji: '🌅', category: 'nature' }, + ], + }, + { + minHours: 12, + maxHours: 16, + references: [ + { text: 'About as long as daylight hours in summer', emoji: '☀️', category: 'nature' }, + { + text: 'Like watching the entire Lord of the Rings trilogy (extended)', + emoji: '🧙', + category: 'media', + }, + ], + }, + { + minHours: 16, + maxHours: 24, + references: [ + { text: 'Almost a full day — impressive!', emoji: '🌍', category: 'nature' }, + { text: 'Like a full day of hiking', emoji: '🥾', category: 'activity' }, + ], + }, + { + minHours: 24, + maxHours: 48, + references: [ + { text: 'More than a full day!', emoji: '🏆', category: 'nature' }, + { text: 'Like a weekend camping trip', emoji: '⛺', category: 'activity' }, + ], + }, + { + minHours: 48, + maxHours: Infinity, + references: [ + { text: 'An extraordinary duration — you are incredible!', emoji: '🌟', category: 'nature' }, + ], + }, +]; + +const FALLBACK: TimeReference = { + text: 'A meaningful amount of time', + emoji: '⏳', + category: 'nature', +}; + +const customRegistry: TimeRangeEntry[] = []; + +function findReferences(hours: number, custom: TimeRangeEntry[]): TimeReference[] { + for (const entry of custom) { + if (hours >= entry.minHours && hours < entry.maxHours) { + return entry.references; + } + } + for (const entry of DEFAULT_DATABASE) { + if (hours >= entry.minHours && hours < entry.maxHours) { + return entry.references; + } + } + return [FALLBACK]; +} + +export function getTimeReference(elapsedHours: number): TimeReference { + const refs = findReferences(Math.max(0, elapsedHours), customRegistry); + const index = Math.floor(Math.random() * refs.length); + return refs[index]; +} + +export function getEpisodeComparison( + elapsedHours: number, + showName = 'The Office', + episodeMins = 22 +): string { + const totalMins = elapsedHours * 60; + const episodes = Math.round(totalMins / episodeMins); + if (episodes <= 0) return `Less than one episode of ${showName}`; + if (episodes === 1) return `About 1 episode of ${showName}`; + return `About ${episodes} episodes of ${showName}`; +} + +export function getEncouragingMessage(elapsedHours: number): string { + if (elapsedHours < 1) return 'Every minute counts — great start!'; + if (elapsedHours < 4) return 'You are building momentum — keep going!'; + if (elapsedHours < 8) return 'Impressive dedication — halfway through the day!'; + if (elapsedHours < 16) return 'Amazing endurance — you are a champion!'; + if (elapsedHours < 24) return 'Nearly a full day — extraordinary commitment!'; + return 'Beyond a full day — you are truly remarkable!'; +} + +export function registerReferences(entries: TimeRangeEntry[]): void { + customRegistry.push(...entries); +} + +export function clearCustomReferences(): void { + customRegistry.length = 0; +} diff --git a/vendor/bytelyst/time-references/src/index.ts b/vendor/bytelyst/time-references/src/index.ts new file mode 100644 index 0000000..56d383e --- /dev/null +++ b/vendor/bytelyst/time-references/src/index.ts @@ -0,0 +1,54 @@ +export interface TimeReference { + emoji: string; + text: string; +} + +export function getTimeReference(hours: number): TimeReference { + if (hours < 1) { + return { emoji: "⏱️", text: "A quick meditation" }; + } + if (hours < 4) { + return { emoji: "🎬", text: "A movie marathon" }; + } + if (hours < 8) { + return { emoji: "✈️", text: "A cross-country flight" }; + } + if (hours < 12) { + return { emoji: "🌙", text: "A full night's sleep" }; + } + if (hours < 16) { + return { emoji: "🏔️", text: "A day hike" }; + } + if (hours < 24) { + return { emoji: "🌍", text: "A day trip abroad" }; + } + if (hours < 36) { + return { emoji: "🚂", text: "A train across the country" }; + } + if (hours < 48) { + return { emoji: "⛵", text: "A weekend sailing trip" }; + } + return { emoji: "🏕️", text: "A multi-day adventure" }; +} + +export function getEncouragingMessage(hours: number): string { + if (hours < 4) { + return "Great start! Your body is beginning to adjust."; + } + if (hours < 8) { + return "You're doing well. Insulin is dropping."; + } + if (hours < 12) { + return "Halfway through a standard fast. Fat burning is ramping up!"; + } + if (hours < 16) { + return "You've passed 12 hours. Autophagy is beginning."; + } + if (hours < 24) { + return "Deep into your fast. Your body is thriving."; + } + if (hours < 36) { + return "Incredible discipline! Growth hormone is surging."; + } + return "Extraordinary commitment. Your body is deeply healing."; +} diff --git a/vendor/bytelyst/time-references/src/types.ts b/vendor/bytelyst/time-references/src/types.ts new file mode 100644 index 0000000..3295480 --- /dev/null +++ b/vendor/bytelyst/time-references/src/types.ts @@ -0,0 +1,16 @@ +/** + * Types for @bytelyst/time-references. + * Pure client-side TS — no backend dependency. + */ + +export interface TimeReference { + text: string; + emoji: string; + category: 'media' | 'activity' | 'travel' | 'nature'; +} + +export interface TimeRangeEntry { + minHours: number; + maxHours: number; + references: TimeReference[]; +} diff --git a/vendor/bytelyst/time-references/tsconfig.json b/vendor/bytelyst/time-references/tsconfig.json new file mode 100644 index 0000000..8c5e8c2 --- /dev/null +++ b/vendor/bytelyst/time-references/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/vendor/bytelyst/ui/.storybook/main.ts b/vendor/bytelyst/ui/.storybook/main.ts new file mode 100644 index 0000000..a01fb35 --- /dev/null +++ b/vendor/bytelyst/ui/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, +}; + +export default config; diff --git a/vendor/bytelyst/ui/.storybook/preview.ts b/vendor/bytelyst/ui/.storybook/preview.ts new file mode 100644 index 0000000..f7c4aa2 --- /dev/null +++ b/vendor/bytelyst/ui/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/react'; + +const preview: Preview = { + parameters: { + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: '#06070A' }, + { name: 'elevated', value: '#0E1118' }, + { name: 'light', value: '#F8F9FC' }, + ], + }, + }, +}; + +export default preview; diff --git a/vendor/bytelyst/ui/README.md b/vendor/bytelyst/ui/README.md new file mode 100644 index 0000000..2437fda --- /dev/null +++ b/vendor/bytelyst/ui/README.md @@ -0,0 +1,239 @@ +# @bytelyst/ui + +Shared component library for the ByteLyst ecosystem. Built with Radix UI primitives, Lucide icons, and CSS custom properties from `@bytelyst/design-tokens`. + +## Install + +```bash +pnpm add @bytelyst/ui +``` + +Peer dependencies: `react`, `react-dom`. + +## Components (15) + +### Button + +5 variants (`primary`, `secondary`, `ghost`, `destructive`, `outline`), 3 sizes, loading state with spinner. + +```tsx +import { Button } from '@bytelyst/ui'; + + + + +``` + +### Input + +Text input with error/success states. Supports `aria-invalid` automatically when `error` is truthy. + +```tsx +import { Input } from '@bytelyst/ui'; + + + +``` + +### Textarea + +Auto-resize textarea with error states. + +```tsx +import { Textarea } from '@bytelyst/ui'; + +