diff --git a/backend/Dockerfile b/backend/Dockerfile index 0392da0..f00108b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,15 +14,17 @@ ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN} ENV BYTELYST_PACKAGE_SOURCE=${BYTELYST_PACKAGE_SOURCE} # Copy workspace root files first (layer cache) -COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY .npmrc .pnpmfile.cjs pnpm-workspace.yaml pnpm-lock.yaml* ./ COPY package.json ./package.json COPY backend/package.json ./backend/package.json +COPY web/package.json ./web/package.json +COPY mobile/package.json ./mobile/package.json # Vendor packages — @bytelyst/* are file: references that must be present before pnpm install COPY vendor/ ./vendor/ -# Install backend deps only -RUN pnpm install --filter @bytelyst/trading-backend +# Install the workspace graph so shared/ files resolve the same way they do locally. +RUN pnpm install -r # Copy source (backend + shared types used by tsconfig rootDir "..") COPY backend/ ./backend/ @@ -42,12 +44,13 @@ ARG BYTELYST_PACKAGE_SOURCE=vendor ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN} ENV BYTELYST_PACKAGE_SOURCE=${BYTELYST_PACKAGE_SOURCE} -COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY .npmrc .pnpmfile.cjs pnpm-workspace.yaml pnpm-lock.yaml* ./ COPY package.json ./package.json COPY backend/package.json ./backend/package.json COPY vendor/ ./vendor/ RUN pnpm install --filter @bytelyst/trading-backend --prod +RUN mkdir -p /app/node_modules && ln -s /app/backend/node_modules/@bytelyst /app/node_modules/@bytelyst COPY --from=builder /app/backend/dist ./backend/dist diff --git a/package.json b/package.json index c7a964c..1ca433f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@bytelyst/kill-switch-client": "file:./vendor/bytelyst/kill-switch-client", - "@bytelyst/react-native-platform-sdk": "^1.0.0", + "@bytelyst/react-native-platform-sdk": "file:./vendor/bytelyst/react-native-platform-sdk", "@bytelyst/react-auth": "file:./vendor/bytelyst/react-auth", "@bytelyst/telemetry-client": "file:./vendor/bytelyst/telemetry-client" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86944d..765b6d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,17 +11,17 @@ importers: .: dependencies: '@bytelyst/kill-switch-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/kill-switch-client - version: file:../../learning_ai_common_plat/packages/kill-switch-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/kill-switch-client + version: file:vendor/bytelyst/kill-switch-client '@bytelyst/react-auth': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/react-auth - version: file:../../learning_ai_common_plat/packages/react-auth(react@19.2.4) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/react-auth + version: file:vendor/bytelyst/react-auth(react@19.2.4) '@bytelyst/react-native-platform-sdk': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/react-native-platform-sdk - version: file:../../learning_ai_common_plat/packages/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/react-native-platform-sdk + version: file:vendor/bytelyst/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@bytelyst/telemetry-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/telemetry-client - version: file:../../learning_ai_common_plat/packages/telemetry-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/telemetry-client + version: file:vendor/bytelyst/telemetry-client devDependencies: typescript: specifier: ^5.9.3 @@ -42,17 +42,17 @@ importers: specifier: ^4.9.0 version: 4.10.0(@azure/core-client@1.10.1) '@bytelyst/auth': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/auth - version: file:../../learning_ai_common_plat/packages/auth(bcryptjs@3.0.3)(jose@6.2.2) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/auth + version: file:vendor/bytelyst/auth(bcryptjs@3.0.3)(jose@6.2.2) '@bytelyst/config': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/config - version: file:../../learning_ai_common_plat/packages/config(@azure/identity@4.13.1)(@azure/keyvault-secrets@4.10.0(@azure/core-client@1.10.1))(zod@4.3.6) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/config + version: file:vendor/bytelyst/config(@azure/identity@4.13.1)(@azure/keyvault-secrets@4.10.0(@azure/core-client@1.10.1))(zod@4.3.6) '@bytelyst/cosmos': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/cosmos - version: file:../../learning_ai_common_plat/packages/cosmos(@azure/cosmos@4.9.2(@azure/core-client@1.10.1)) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/cosmos + version: file:vendor/bytelyst/cosmos(@azure/cosmos@4.9.2(@azure/core-client@1.10.1)) '@bytelyst/llm': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/llm - version: file:../../learning_ai_common_plat/packages/llm + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/llm + version: file:vendor/bytelyst/llm '@supabase/supabase-js': specifier: ^2.90.1 version: 2.101.1 @@ -106,14 +106,14 @@ importers: mobile: dependencies: '@bytelyst/kill-switch-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/kill-switch-client - version: file:../../learning_ai_common_plat/packages/kill-switch-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/kill-switch-client + version: file:vendor/bytelyst/kill-switch-client '@bytelyst/react-native-platform-sdk': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/react-native-platform-sdk - version: file:../../learning_ai_common_plat/packages/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/react-native-platform-sdk + version: file:vendor/bytelyst/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) '@bytelyst/telemetry-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/telemetry-client - version: file:../../learning_ai_common_plat/packages/telemetry-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/telemetry-client + version: file:vendor/bytelyst/telemetry-client '@expo-google-fonts/inter': specifier: ^0.4.2 version: 0.4.2 @@ -245,20 +245,20 @@ importers: web: dependencies: '@bytelyst/api-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/api-client - version: file:../../learning_ai_common_plat/packages/api-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/api-client + version: file:vendor/bytelyst/api-client '@bytelyst/errors': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/errors - version: file:../../learning_ai_common_plat/packages/errors + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/errors + version: file:vendor/bytelyst/errors '@bytelyst/kill-switch-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/kill-switch-client - version: file:../../learning_ai_common_plat/packages/kill-switch-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/kill-switch-client + version: file:vendor/bytelyst/kill-switch-client '@bytelyst/react-auth': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/react-auth - version: file:../../learning_ai_common_plat/packages/react-auth(react@19.2.4) + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/react-auth + version: file:vendor/bytelyst/react-auth(react@19.2.4) '@bytelyst/telemetry-client': - specifier: file:/opt/bytelyst/learning_ai_common_plat/packages/telemetry-client - version: file:../../learning_ai_common_plat/packages/telemetry-client + specifier: file:/opt/bytelyst/trading/learning_ai_invt_trdg/vendor/bytelyst/telemetry-client + version: file:vendor/bytelyst/telemetry-client '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -990,17 +990,17 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@bytelyst/api-client@file:../../learning_ai_common_plat/packages/api-client': - resolution: {directory: ../../learning_ai_common_plat/packages/api-client, type: directory} + '@bytelyst/api-client@file:vendor/bytelyst/api-client': + resolution: {directory: vendor/bytelyst/api-client, type: directory} - '@bytelyst/auth@file:../../learning_ai_common_plat/packages/auth': - resolution: {directory: ../../learning_ai_common_plat/packages/auth, type: directory} + '@bytelyst/auth@file:vendor/bytelyst/auth': + resolution: {directory: vendor/bytelyst/auth, type: directory} peerDependencies: bcryptjs: '>=2.4.0' jose: '>=5.0.0' - '@bytelyst/config@file:../../learning_ai_common_plat/packages/config': - resolution: {directory: ../../learning_ai_common_plat/packages/config, type: directory} + '@bytelyst/config@file:vendor/bytelyst/config': + resolution: {directory: vendor/bytelyst/config, type: directory} peerDependencies: '@azure/identity': '>=4.0.0' '@azure/keyvault-secrets': '>=4.8.0' @@ -1011,34 +1011,34 @@ packages: '@azure/keyvault-secrets': optional: true - '@bytelyst/cosmos@file:../../learning_ai_common_plat/packages/cosmos': - resolution: {directory: ../../learning_ai_common_plat/packages/cosmos, type: directory} + '@bytelyst/cosmos@file:vendor/bytelyst/cosmos': + resolution: {directory: vendor/bytelyst/cosmos, type: directory} peerDependencies: '@azure/cosmos': '>=4.0.0' - '@bytelyst/errors@file:../../learning_ai_common_plat/packages/errors': - resolution: {directory: ../../learning_ai_common_plat/packages/errors, type: directory} + '@bytelyst/errors@file:vendor/bytelyst/errors': + resolution: {directory: vendor/bytelyst/errors, type: directory} - '@bytelyst/kill-switch-client@file:../../learning_ai_common_plat/packages/kill-switch-client': - resolution: {directory: ../../learning_ai_common_plat/packages/kill-switch-client, type: directory} + '@bytelyst/kill-switch-client@file:vendor/bytelyst/kill-switch-client': + resolution: {directory: vendor/bytelyst/kill-switch-client, type: directory} - '@bytelyst/llm@file:../../learning_ai_common_plat/packages/llm': - resolution: {directory: ../../learning_ai_common_plat/packages/llm, type: directory} + '@bytelyst/llm@file:vendor/bytelyst/llm': + resolution: {directory: vendor/bytelyst/llm, type: directory} - '@bytelyst/react-auth@file:../../learning_ai_common_plat/packages/react-auth': - resolution: {directory: ../../learning_ai_common_plat/packages/react-auth, type: directory} + '@bytelyst/react-auth@file:vendor/bytelyst/react-auth': + resolution: {directory: vendor/bytelyst/react-auth, type: directory} peerDependencies: react: '>=18.0.0' - '@bytelyst/react-native-platform-sdk@file:../../learning_ai_common_plat/packages/react-native-platform-sdk': - resolution: {directory: ../../learning_ai_common_plat/packages/react-native-platform-sdk, type: directory} + '@bytelyst/react-native-platform-sdk@file:vendor/bytelyst/react-native-platform-sdk': + resolution: {directory: vendor/bytelyst/react-native-platform-sdk, type: directory} peerDependencies: expo: '>=49.0.0' react: '>=18.0.0' react-native: '>=0.72.0' - '@bytelyst/telemetry-client@file:../../learning_ai_common_plat/packages/telemetry-client': - resolution: {directory: ../../learning_ai_common_plat/packages/telemetry-client, type: directory} + '@bytelyst/telemetry-client@file:vendor/bytelyst/telemetry-client': + resolution: {directory: vendor/bytelyst/telemetry-client, type: directory} '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} @@ -7442,49 +7442,49 @@ snapshots: dependencies: css-tree: 3.2.1 - '@bytelyst/api-client@file:../../learning_ai_common_plat/packages/api-client': {} + '@bytelyst/api-client@file:vendor/bytelyst/api-client': {} - '@bytelyst/auth@file:../../learning_ai_common_plat/packages/auth(bcryptjs@3.0.3)(jose@6.2.2)': + '@bytelyst/auth@file:vendor/bytelyst/auth(bcryptjs@3.0.3)(jose@6.2.2)': dependencies: - '@bytelyst/errors': file:../../learning_ai_common_plat/packages/errors + '@bytelyst/errors': file:vendor/bytelyst/errors bcryptjs: 3.0.3 jose: 6.2.2 - '@bytelyst/config@file:../../learning_ai_common_plat/packages/config(@azure/identity@4.13.1)(@azure/keyvault-secrets@4.10.0(@azure/core-client@1.10.1))(zod@4.3.6)': + '@bytelyst/config@file:vendor/bytelyst/config(@azure/identity@4.13.1)(@azure/keyvault-secrets@4.10.0(@azure/core-client@1.10.1))(zod@4.3.6)': dependencies: zod: 4.3.6 optionalDependencies: '@azure/identity': 4.13.1 '@azure/keyvault-secrets': 4.10.0(@azure/core-client@1.10.1) - '@bytelyst/cosmos@file:../../learning_ai_common_plat/packages/cosmos(@azure/cosmos@4.9.2(@azure/core-client@1.10.1))': + '@bytelyst/cosmos@file:vendor/bytelyst/cosmos(@azure/cosmos@4.9.2(@azure/core-client@1.10.1))': dependencies: '@azure/cosmos': 4.9.2(@azure/core-client@1.10.1) - '@bytelyst/errors@file:../../learning_ai_common_plat/packages/errors': {} + '@bytelyst/errors@file:vendor/bytelyst/errors': {} - '@bytelyst/kill-switch-client@file:../../learning_ai_common_plat/packages/kill-switch-client': {} + '@bytelyst/kill-switch-client@file:vendor/bytelyst/kill-switch-client': {} - '@bytelyst/llm@file:../../learning_ai_common_plat/packages/llm': {} + '@bytelyst/llm@file:vendor/bytelyst/llm': {} - '@bytelyst/react-auth@file:../../learning_ai_common_plat/packages/react-auth(react@19.2.4)': + '@bytelyst/react-auth@file:vendor/bytelyst/react-auth(react@19.2.4)': dependencies: - '@bytelyst/api-client': file:../../learning_ai_common_plat/packages/api-client + '@bytelyst/api-client': file:vendor/bytelyst/api-client react: 19.2.4 - '@bytelyst/react-native-platform-sdk@file:../../learning_ai_common_plat/packages/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': + '@bytelyst/react-native-platform-sdk@file:vendor/bytelyst/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': dependencies: expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) react: 19.1.0 react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.1.0) - '@bytelyst/react-native-platform-sdk@file:../../learning_ai_common_plat/packages/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)': + '@bytelyst/react-native-platform-sdk@file:vendor/bytelyst/react-native-platform-sdk(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)': dependencies: expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react: 19.2.4 react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4) - '@bytelyst/telemetry-client@file:../../learning_ai_common_plat/packages/telemetry-client': {} + '@bytelyst/telemetry-client@file:vendor/bytelyst/telemetry-client': {} '@colors/colors@1.6.0': {} diff --git a/vendor/bytelyst/api-client/package.json b/vendor/bytelyst/api-client/package.json index 24e445e..0261f1f 100644 --- a/vendor/bytelyst/api-client/package.json +++ b/vendor/bytelyst/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/api-client", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "exports": { ".": { diff --git a/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts b/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts new file mode 100644 index 0000000..1e14e65 --- /dev/null +++ b/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createApiClient } from '../index.js'; + +// Mock globalThis.fetch +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + }; +} + +describe('createApiClient', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('returns an object with fetch and safeFetch', () => { + const api = createApiClient({ baseUrl: 'http://localhost:4003' }); + expect(typeof api.fetch).toBe('function'); + expect(typeof api.safeFetch).toBe('function'); + }); + + describe('fetch', () => { + it('calls correct URL with base + path', async () => { + mockFetch.mockResolvedValue(jsonResponse({ users: [] })); + const api = createApiClient({ baseUrl: 'http://localhost:4003/api' }); + + await api.fetch('/users'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:4003/api/users', + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + }) + ); + }); + + it('returns parsed JSON on success', async () => { + mockFetch.mockResolvedValue(jsonResponse({ id: '1', name: 'Test' })); + const api = createApiClient({ baseUrl: '/api' }); + + const result = await api.fetch<{ id: string; name: string }>('/users/1'); + expect(result).toEqual({ id: '1', name: 'Test' }); + }); + + it('throws on HTTP error', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'Not found' }, 404)); + const api = createApiClient({ baseUrl: '/api' }); + + await expect(api.fetch('/users/999')).rejects.toThrow('Not found'); + }); + + it('injects auth token from getToken', async () => { + mockFetch.mockResolvedValue(jsonResponse({ ok: true })); + const api = createApiClient({ + baseUrl: '/api', + getToken: () => 'my-jwt-token', + }); + + await api.fetch('/protected'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/protected', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-jwt-token', + }), + }) + ); + }); + + it('skips auth header when getToken returns null', async () => { + mockFetch.mockResolvedValue(jsonResponse({ ok: true })); + const api = createApiClient({ + baseUrl: '/api', + getToken: () => null, + }); + + await api.fetch('/public'); + + const headers = mockFetch.mock.calls[0][1].headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it('merges defaultHeaders', async () => { + mockFetch.mockResolvedValue(jsonResponse({ ok: true })); + const api = createApiClient({ + baseUrl: '/api', + defaultHeaders: { 'X-Custom': 'value' }, + }); + + await api.fetch('/test'); + + const headers = mockFetch.mock.calls[0][1].headers as Record; + expect(headers['X-Custom']).toBe('value'); + expect(headers['Content-Type']).toBe('application/json'); + }); + }); + + describe('safeFetch', () => { + it('returns { data, error: null } on success', async () => { + mockFetch.mockResolvedValue(jsonResponse({ id: '1' })); + const api = createApiClient({ baseUrl: '/api' }); + + const result = await api.safeFetch<{ id: string }>('/items/1'); + expect(result.data).toEqual({ id: '1' }); + expect(result.error).toBeNull(); + }); + + it('returns { data: null, error } on HTTP error', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403)); + const api = createApiClient({ baseUrl: '/api' }); + + const result = await api.safeFetch('/secret'); + expect(result.data).toBeNull(); + expect(result.error).toBe('Forbidden'); + }); + + it('returns { data: null, error } on network error', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + const api = createApiClient({ baseUrl: '/api' }); + + const result = await api.safeFetch('/unreachable'); + expect(result.data).toBeNull(); + expect(result.error).toBe('API unavailable'); + }); + }); +}); diff --git a/vendor/bytelyst/api-client/src/client.ts b/vendor/bytelyst/api-client/src/client.ts new file mode 100644 index 0000000..8230966 --- /dev/null +++ b/vendor/bytelyst/api-client/src/client.ts @@ -0,0 +1,151 @@ +/** + * Configurable API client factory. + * Creates a fetch wrapper with base URL, auth token injection, error handling, + * timeout, and retry with exponential backoff for idempotent requests. + */ + +import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; + +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Create an API client with a base URL and optional auth token. + * + * @example + * ```ts + * const api = createApiClient({ + * baseUrl: "/api", + * getToken: () => localStorage.getItem("access_token"), + * }); + * + * // Throws on error + * const users = await api.fetch("/users"); + * + * // Never throws + * const { data, error } = await api.safeFetch("/users"); + * ``` + */ +export function createApiClient(config: ApiClientConfig): ApiClient { + const { + baseUrl, + getToken, + defaultHeaders, + timeoutMs = 10_000, + retries = 2, + retryDelayMs = 500, + } = config; + + function buildHeaders(options?: RequestInit): HeadersInit { + const headers: Record = { + 'Content-Type': 'application/json', + 'x-request-id': + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + ...defaultHeaders, + }; + + if (getToken) { + const token = getToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + + if (options?.headers) { + const extra: Record = {}; + if (options.headers instanceof Headers) { + options.headers.forEach((value, key) => { + extra[key] = value; + }); + } else if (Array.isArray(options.headers)) { + Object.assign(extra, Object.fromEntries(options.headers)); + } else { + Object.assign(extra, options.headers); + } + Object.assign(headers, extra); + } + + return headers; + } + + function buildInit(options?: RequestInit): RequestInit { + const init: RequestInit = { + ...options, + headers: buildHeaders(options), + }; + + // AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0) + if (timeoutMs > 0 && !options?.signal) { + const controller = new AbortController(); + init.signal = controller.signal; + setTimeout(() => controller.abort(), timeoutMs); + } + + return init; + } + + function isRetryable(method: string | undefined): boolean { + return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()); + } + + async function fetchWithRetry(url: string, init: RequestInit): Promise { + const maxAttempts = isRetryable(init.method) ? retries + 1 : 1; + let lastError: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const res = await globalThis.fetch(url, init); + // Only retry on 502/503/504 for idempotent methods + if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) { + await sleep(retryDelayMs * 2 ** attempt); + continue; + } + return res; + } catch (err) { + lastError = err; + if (attempt < maxAttempts - 1) { + await sleep(retryDelayMs * 2 ** attempt); + continue; + } + } + } + + throw lastError; + } + + return { + async fetch(path: string, options?: RequestInit): Promise { + const init = buildInit(options); + const res = await fetchWithRetry(`${baseUrl}${path}`, init); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(body.error || `HTTP ${res.status}`); + } + + return res.json() as Promise; + }, + + async safeFetch(path: string, options?: RequestInit): Promise> { + try { + const init = buildInit(options); + const res = await fetchWithRetry(`${baseUrl}${path}`, init); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + return { data: null, error: body.error || `HTTP ${res.status}` }; + } + + const data = (await res.json()) as T; + return { data, error: null }; + } catch { + return { data: null, error: 'API unavailable' }; + } + }, + }; +} diff --git a/vendor/bytelyst/api-client/src/index.ts b/vendor/bytelyst/api-client/src/index.ts new file mode 100644 index 0000000..4805981 --- /dev/null +++ b/vendor/bytelyst/api-client/src/index.ts @@ -0,0 +1,2 @@ +export { createApiClient } from './client.js'; +export type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; diff --git a/vendor/bytelyst/api-client/src/types.ts b/vendor/bytelyst/api-client/src/types.ts new file mode 100644 index 0000000..a4252a6 --- /dev/null +++ b/vendor/bytelyst/api-client/src/types.ts @@ -0,0 +1,28 @@ +export interface ApiClientConfig { + baseUrl: string; + getToken?: () => string | null; + defaultHeaders?: Record; + /** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */ + timeoutMs?: number; + /** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */ + retries?: number; + /** Base delay in ms for exponential backoff between retries. Default: 500. */ + retryDelayMs?: number; +} + +export interface ApiResult { + data: T | null; + error: string | null; +} + +export interface ApiClient { + /** + * Fetch that throws on error — use when caller handles errors. + */ + fetch(path: string, options?: RequestInit): Promise; + + /** + * Safe fetch that never throws — returns { data, error } tuple. + */ + safeFetch(path: string, options?: RequestInit): Promise>; +} diff --git a/vendor/bytelyst/api-client/tsconfig.json b/vendor/bytelyst/api-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/api-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/package.json b/vendor/bytelyst/auth/package.json index 8fc1a5b..28e2c24 100644 --- a/vendor/bytelyst/auth/package.json +++ b/vendor/bytelyst/auth/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/auth", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "exports": { ".": { @@ -27,4 +27,4 @@ "publishConfig": { "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" } -} \ No newline at end of file +} diff --git a/vendor/bytelyst/auth/src/__tests__/auth.test.ts b/vendor/bytelyst/auth/src/__tests__/auth.test.ts new file mode 100644 index 0000000..fbb7384 --- /dev/null +++ b/vendor/bytelyst/auth/src/__tests__/auth.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { createJwtUtils, hashPassword, verifyPassword } from '../index.js'; + +describe('JWT utilities', () => { + const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; + + beforeAll(() => { + process.env.JWT_SECRET = SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('creates and verifies an access token', async () => { + const jwt = createJwtUtils({ issuer: 'test-issuer' }); + const token = await jwt.createAccessToken({ + sub: 'user-1', + email: 'test@example.com', + role: 'admin', + }); + + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.email).toBe('test@example.com'); + expect(payload!.role).toBe('admin'); + expect(payload!.type).toBe('access'); + }); + + it('creates and verifies a refresh token', async () => { + const jwt = createJwtUtils({ issuer: 'test-issuer' }); + const token = await jwt.createRefreshToken({ sub: 'user-1' }); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.type).toBe('refresh'); + }); + + it('returns null for invalid token', async () => { + const jwt = createJwtUtils({ issuer: 'test-issuer' }); + const result = await jwt.verifyToken('garbage.not.valid'); + expect(result).toBeNull(); + }); + + it('returns null for wrong issuer', async () => { + const jwt1 = createJwtUtils({ issuer: 'issuer-a' }); + const jwt2 = createJwtUtils({ issuer: 'issuer-b' }); + + const token = await jwt1.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + + const result = await jwt2.verifyToken(token); + expect(result).toBeNull(); + }); + + it('sets productId from payload or defaults to issuer', async () => { + const jwt = createJwtUtils({ issuer: 'lysnrai' }); + + const t1 = await jwt.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + const p1 = await jwt.verifyToken(t1); + expect(p1!.productId).toBe('lysnrai'); + + const t2 = await jwt.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + productId: 'mindlyst', + }); + const p2 = await jwt.verifyToken(t2); + expect(p2!.productId).toBe('mindlyst'); + }); + + it('respects custom expiry', async () => { + const jwt = createJwtUtils({ + issuer: 'test', + accessTokenExpiry: '2h', + refreshTokenExpiry: '7d', + }); + + const access = await jwt.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + const refresh = await jwt.createRefreshToken({ sub: 'u1' }); + + expect(typeof access).toBe('string'); + expect(typeof refresh).toBe('string'); + }); + + it('throws when JWT_SECRET is not set', async () => { + const origSecret = process.env.JWT_SECRET; + delete process.env.JWT_SECRET; + + const jwt = createJwtUtils({ issuer: 'test' }); + await expect( + jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) + ).rejects.toThrow('JWT_SECRET must be set'); + + process.env.JWT_SECRET = origSecret; + }); +}); + +describe('password hashing', () => { + it('hashes a password and verifies it', async () => { + const hash = await hashPassword('MySecret123!'); + expect(typeof hash).toBe('string'); + expect(hash).not.toBe('MySecret123!'); + + const valid = await verifyPassword('MySecret123!', hash); + expect(valid).toBe(true); + }); + + it('rejects wrong password', async () => { + const hash = await hashPassword('correct-password'); + const valid = await verifyPassword('wrong-password', hash); + expect(valid).toBe(false); + }); + + it('produces different hashes for same input', async () => { + const h1 = await hashPassword('same'); + const h2 = await hashPassword('same'); + expect(h1).not.toBe(h2); // different salts + }); +}); diff --git a/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts b/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts new file mode 100644 index 0000000..3d35bbc --- /dev/null +++ b/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts @@ -0,0 +1,101 @@ +/** + * End-to-end auth flow test: create token → extract auth → require role. + * Exercises the full JWT lifecycle without network calls. + */ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { + createJwtUtils, + extractAuth, + requireRole, + hashPassword, + verifyPassword, +} from '../index.js'; + +const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!'; + +describe('E2E auth flow', () => { + beforeAll(() => { + process.env.JWT_SECRET = SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('full flow: login credentials → JWT → authenticated request → role check', async () => { + // 1. Simulate password verification (login step) + const storedHash = await hashPassword('SecretPass123!'); + const passwordValid = await verifyPassword('SecretPass123!', storedHash); + expect(passwordValid).toBe(true); + + // 2. Issue access token (platform-service would do this) + const jwt = createJwtUtils({ issuer: 'lysnrai' }); + const accessToken = await jwt.createAccessToken({ + sub: 'user-admin-001', + email: 'admin@lysnrai.com', + role: 'super_admin', + productId: 'lysnrai', + }); + expect(typeof accessToken).toBe('string'); + + // 3. Simulate authenticated request (any service receives this) + const req = { headers: { authorization: `Bearer ${accessToken}` } }; + const auth = await extractAuth(req); + expect(auth.sub).toBe('user-admin-001'); + expect(auth.email).toBe('admin@lysnrai.com'); + expect(auth.role).toBe('super_admin'); + expect(auth.productId).toBe('lysnrai'); + + // 4. Role-gated endpoint check + const adminAuth = await requireRole(req, 'super_admin', 'admin'); + expect(adminAuth.sub).toBe('user-admin-001'); + + // 5. Role rejection for wrong role + await expect(requireRole(req, 'viewer')).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('refresh token cannot be used for authenticated requests', async () => { + const jwt = createJwtUtils({ issuer: 'lysnrai' }); + const refreshToken = await jwt.createRefreshToken({ + sub: 'user-001', + productId: 'lysnrai', + }); + + const req = { headers: { authorization: `Bearer ${refreshToken}` } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => { + // extractAuth only checks type=access via jwtVerify without issuer + // But verifyToken checks issuer — this is the cross-service security model + const jwtA = createJwtUtils({ issuer: 'lysnrai' }); + const jwtB = createJwtUtils({ issuer: 'mindlyst' }); + + const tokenA = await jwtA.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + + // verifyToken with wrong issuer rejects + const resultB = await jwtB.verifyToken(tokenA); + expect(resultB).toBeNull(); + + // verifyToken with correct issuer passes + const resultA = await jwtA.verifyToken(tokenA); + expect(resultA).not.toBeNull(); + expect(resultA!.sub).toBe('u1'); + }); + + it('wrong password fails login flow before token issuance', async () => { + const storedHash = await hashPassword('CorrectPassword'); + const passwordValid = await verifyPassword('WrongPassword', storedHash); + expect(passwordValid).toBe(false); + // No token should be issued — the flow stops here + }); +}); diff --git a/vendor/bytelyst/auth/src/__tests__/middleware.test.ts b/vendor/bytelyst/auth/src/__tests__/middleware.test.ts new file mode 100644 index 0000000..fb498d3 --- /dev/null +++ b/vendor/bytelyst/auth/src/__tests__/middleware.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { createJwtUtils, extractAuth, requireRole } from '../index.js'; + +const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; +let validAccessToken: string; +let refreshToken: string; + +describe('extractAuth', () => { + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + const jwt = createJwtUtils({ issuer: 'test-issuer' }); + validAccessToken = await jwt.createAccessToken({ + sub: 'user-1', + email: 'test@example.com', + role: 'admin', + productId: 'lysnrai', + }); + refreshToken = await jwt.createRefreshToken({ sub: 'user-1' }); + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('extracts auth from valid Bearer token', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await extractAuth(req); + expect(payload.sub).toBe('user-1'); + expect(payload.email).toBe('test@example.com'); + expect(payload.role).toBe('admin'); + expect(payload.productId).toBe('lysnrai'); + expect(payload.type).toBe('access'); + }); + + it('throws 401 when no authorization header', async () => { + const req = { headers: {} }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Unauthorized', + }); + }); + + it('throws 401 when authorization header is not Bearer', async () => { + const req = { headers: { authorization: 'Basic abc123' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Unauthorized', + }); + }); + + it('throws 401 for invalid token', async () => { + const req = { headers: { authorization: 'Bearer garbage.not.valid' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('throws 401 for refresh token (requires access type)', async () => { + const req = { headers: { authorization: `Bearer ${refreshToken}` } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('throws 401 for empty Bearer value', async () => { + const req = { headers: { authorization: 'Bearer ' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + }); + }); +}); + +describe('requireRole', () => { + beforeAll(() => { + process.env.JWT_SECRET = SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('passes when role matches', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req, 'admin'); + expect(payload.sub).toBe('user-1'); + expect(payload.role).toBe('admin'); + }); + + it('passes when role is in allowed list', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req, 'viewer', 'admin', 'super_admin'); + expect(payload.role).toBe('admin'); + }); + + it('throws 403 when role does not match', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({ + statusCode: 403, + message: 'Insufficient permissions', + }); + }); + + it('passes with no roles specified (any authenticated user)', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req); + expect(payload.sub).toBe('user-1'); + }); + + it('throws 401 when no auth header (before checking role)', async () => { + const req = { headers: {} }; + await expect(requireRole(req, 'admin')).rejects.toMatchObject({ + statusCode: 401, + }); + }); +}); diff --git a/vendor/bytelyst/auth/src/__tests__/rs256.test.ts b/vendor/bytelyst/auth/src/__tests__/rs256.test.ts new file mode 100644 index 0000000..a96832c --- /dev/null +++ b/vendor/bytelyst/auth/src/__tests__/rs256.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { generateKeyPair } from 'jose'; +import { createJwtUtils } from '../index.js'; + +describe('JWT RS256 support (Phase 4C)', () => { + const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; + let rsaPrivateKey: string; + let rsaPublicKey: string; + + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + + // Generate RSA key pair for testing (extractable required for PEM export in jose v6) + const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); + const { exportPKCS8, exportSPKI } = await import('jose'); + rsaPrivateKey = await exportPKCS8(privateKey); + rsaPublicKey = await exportSPKI(publicKey); + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('signs and verifies tokens with RS256', async () => { + const jwt = createJwtUtils({ + issuer: 'test-rs256', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await jwt.createAccessToken({ + sub: 'user-1', + email: 'rs256@test.com', + role: 'admin', + }); + + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.email).toBe('rs256@test.com'); + expect(payload!.type).toBe('access'); + }); + + it('dual verify: RS256 verifier can fall back to HS256 tokens', async () => { + // Create an HS256 token (simulates old tokens during migration) + const hs256Jwt = createJwtUtils({ issuer: 'dual-test' }); + const hs256Token = await hs256Jwt.createAccessToken({ + sub: 'u-old', + email: 'old@test.com', + role: 'user', + }); + + // Verify with RS256-configured jwt (should fall back to HS256) + const dualJwt = createJwtUtils({ + issuer: 'dual-test', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const payload = await dualJwt.verifyToken(hs256Token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('u-old'); + expect(payload!.email).toBe('old@test.com'); + }); + + it('RS256 token is NOT verified by HS256-only verifier with different issuer', async () => { + const rs256Jwt = createJwtUtils({ + issuer: 'rs256-only', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await rs256Jwt.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + + // HS256-only verifier with different issuer should reject + const hs256Jwt = createJwtUtils({ issuer: 'different-issuer' }); + const result = await hs256Jwt.verifyToken(token); + expect(result).toBeNull(); + }); + + it('RS256 refresh token works', async () => { + const jwt = createJwtUtils({ + issuer: 'test-rs256', + algorithm: 'RS256', + rsaPrivateKey, + rsaPublicKey, + }); + + const token = await jwt.createRefreshToken({ sub: 'user-1' }); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('user-1'); + expect(payload!.type).toBe('refresh'); + }); + + it('throws when RS256 signing without private key', async () => { + const jwt = createJwtUtils({ + issuer: 'test-no-key', + algorithm: 'RS256', + rsaPublicKey, // public only, no private + }); + + await expect( + jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) + ).rejects.toThrow('rsaPrivateKey is required'); + }); + + it('HS256 still works as default (backward compatible)', async () => { + const jwt = createJwtUtils({ issuer: 'hs256-compat' }); + + const token = await jwt.createAccessToken({ + sub: 'u1', + email: 'compat@test.com', + role: 'user', + }); + + const payload = await jwt.verifyToken(token); + expect(payload).not.toBeNull(); + expect(payload!.sub).toBe('u1'); + expect(payload!.email).toBe('compat@test.com'); + }); +}); diff --git a/vendor/bytelyst/auth/src/index.ts b/vendor/bytelyst/auth/src/index.ts new file mode 100644 index 0000000..f00bf95 --- /dev/null +++ b/vendor/bytelyst/auth/src/index.ts @@ -0,0 +1,5 @@ +export { createJwtUtils } from './jwt.js'; +export { extractAuth, requireRole } from './middleware.js'; +export { hashPassword, verifyPassword } from './password.js'; +export { getCurrentUser } from './server-auth.js'; +export type { TokenPayload, AuthPayload, JwtUtilsOptions, JwtUtils } from './types.js'; diff --git a/vendor/bytelyst/auth/src/jwt.ts b/vendor/bytelyst/auth/src/jwt.ts new file mode 100644 index 0000000..57f8bb1 --- /dev/null +++ b/vendor/bytelyst/auth/src/jwt.ts @@ -0,0 +1,176 @@ +/** + * JWT utilities — configurable issuer, expiry, and algorithm. + * Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose. + * + * RS256 mode (Phase 4C SmartAuth): + * - Sign with RSA private key (PEM) + * - Verify with RSA public key (PEM) or remote JWKS URL + * - Dual verification: tries RS256 first, falls back to HS256 during migration + */ + +import { + SignJWT, + jwtVerify, + importPKCS8, + importSPKI, + createRemoteJWKSet, + type CryptoKey as JoseCryptoKey, +} from 'jose'; +import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js'; + +function getHmacSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET must be set'); + return new TextEncoder().encode(secret); +} + +/** + * Create a JWT utility set with the given issuer and expiry configuration. + * + * @example + * ```ts + * // HS256 (default, backward-compatible) + * const jwt = createJwtUtils({ issuer: "bytelyst-platform" }); + * + * // RS256 (SmartAuth Phase 4C) + * const jwt = createJwtUtils({ + * issuer: "bytelyst-platform", + * algorithm: "RS256", + * rsaPrivateKey: process.env.JWT_PRIVATE_KEY, + * rsaPublicKey: process.env.JWT_PUBLIC_KEY, + * }); + * + * // RS256 verify-only (product backends — no private key) + * const jwt = createJwtUtils({ + * issuer: "bytelyst-platform", + * algorithm: "RS256", + * jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json", + * }); + * ``` + */ +export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { + const { + issuer, + accessTokenExpiry = '1h', + refreshTokenExpiry = '30d', + algorithm = 'HS256', + rsaPrivateKey, + rsaPublicKey, + jwksUrl, + } = options; + + // ── Key caches ──────────────────────────────────── + + let _rsaPrivateKeyObj: JoseCryptoKey | null = null; + let _rsaPublicKeyObj: JoseCryptoKey | null = null; + let _jwksKeySet: ReturnType | null = null; + + async function getRsaPrivateKey(): Promise { + if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj; + if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing'); + _rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey; + return _rsaPrivateKeyObj; + } + + async function getRsaPublicKey(): Promise { + if (_rsaPublicKeyObj) return _rsaPublicKeyObj; + if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification'); + _rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey; + return _rsaPublicKeyObj; + } + + function getJwksKeySet(): ReturnType { + if (_jwksKeySet) return _jwksKeySet; + if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification'); + _jwksKeySet = createRemoteJWKSet(new URL(jwksUrl)); + return _jwksKeySet; + } + + // ── Signing ─────────────────────────────────────── + + async function sign(claims: Record, expiry: string): Promise { + if (algorithm === 'RS256') { + const key = await getRsaPrivateKey(); + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime(expiry) + .setIssuer(issuer) + .sign(key); + } + return new SignJWT(claims) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(expiry) + .setIssuer(issuer) + .sign(getHmacSecret()); + } + + // ── Verification (dual: RS256 first, HS256 fallback) ── + + async function verifyWithRS256(token: string): Promise { + try { + if (jwksUrl) { + const keySet = getJwksKeySet(); + const { payload } = await jwtVerify(token, keySet, { issuer }); + return payload as unknown as TokenPayload; + } + if (rsaPublicKey) { + const key = await getRsaPublicKey(); + const { payload } = await jwtVerify(token, key, { issuer }); + return payload as unknown as TokenPayload; + } + return null; + } catch { + return null; + } + } + + async function verifyWithHS256(token: string): Promise { + try { + const secret = getHmacSecret(); + const { payload } = await jwtVerify(token, secret, { issuer }); + return payload as unknown as TokenPayload; + } catch { + return null; + } + } + + return { + async createAccessToken(payload) { + return sign( + { + ...payload, + productId: payload.productId || issuer, + type: 'access', + }, + accessTokenExpiry + ); + }, + + async createRefreshToken(payload) { + return sign( + { + sub: payload.sub, + productId: payload.productId || issuer, + type: 'refresh', + }, + refreshTokenExpiry + ); + }, + + async verifyToken(token: string) { + // Dual verification: try RS256 first (if configured), then HS256 fallback + if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) { + const rs256Result = await verifyWithRS256(token); + if (rs256Result) return rs256Result; + } + // HS256 fallback (safe during migration; removed after full RS256 rollout) + try { + return await verifyWithHS256(token); + } catch { + return null; + } + }, + }; +} diff --git a/vendor/bytelyst/auth/src/middleware.ts b/vendor/bytelyst/auth/src/middleware.ts new file mode 100644 index 0000000..3cbdf5f --- /dev/null +++ b/vendor/bytelyst/auth/src/middleware.ts @@ -0,0 +1,54 @@ +/** + * Fastify auth middleware — validates JWT tokens from Authorization headers. + */ + +import { jwtVerify } from 'jose'; +import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; +import type { AuthPayload } from './types.js'; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET must be set'); + return new TextEncoder().encode(secret); +} + +/** + * Extract and verify auth payload from an Authorization header. + * Works with any request-like object that has headers.authorization. + * + * @throws Error with message "Unauthorized" if no valid Bearer token + * @throws Error with message "Invalid or expired token" if verification fails + */ +export 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 { + const { payload } = await jwtVerify(token, getSecret()); + 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. + * + * @throws Error with statusCode 403 if role doesn't match + */ +export 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; +} diff --git a/vendor/bytelyst/auth/src/password.ts b/vendor/bytelyst/auth/src/password.ts new file mode 100644 index 0000000..296a885 --- /dev/null +++ b/vendor/bytelyst/auth/src/password.ts @@ -0,0 +1,15 @@ +/** + * Password hashing utilities using bcryptjs. + */ + +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, SALT_ROUNDS); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/vendor/bytelyst/auth/src/server-auth.ts b/vendor/bytelyst/auth/src/server-auth.ts new file mode 100644 index 0000000..1c4668a --- /dev/null +++ b/vendor/bytelyst/auth/src/server-auth.ts @@ -0,0 +1,26 @@ +/** + * Server-side auth helpers for Next.js API routes. + */ + +import type { TokenPayload } from './types.js'; + +/** + * Get the current user from an Authorization header value. + * Pairs with a verifyToken function and a getUserById function. + * + * @param authHeader - The Authorization header value (e.g., "Bearer xxx") + * @param verifyToken - Function to verify the JWT and return a payload + * @param getUserById - Function to look up the user by their ID + * @returns The user object or null if auth fails + */ +export async function getCurrentUser( + authHeader: string | null, + verifyToken: (token: string) => Promise, + getUserById: (id: string) => Promise +): Promise { + if (!authHeader?.startsWith('Bearer ')) return null; + const token = authHeader.slice(7); + const payload = await verifyToken(token); + if (!payload || payload.type !== 'access') return null; + return getUserById(payload.sub); +} diff --git a/vendor/bytelyst/auth/src/types.ts b/vendor/bytelyst/auth/src/types.ts new file mode 100644 index 0000000..6455fbe --- /dev/null +++ b/vendor/bytelyst/auth/src/types.ts @@ -0,0 +1,41 @@ +export interface TokenPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: 'access' | 'refresh'; + [key: string]: unknown; +} + +export interface AuthPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +} + +export interface JwtUtilsOptions { + issuer: string; + accessTokenExpiry?: string; + refreshTokenExpiry?: string; + /** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */ + algorithm?: 'HS256' | 'RS256'; + /** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */ + rsaPrivateKey?: string; + /** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */ + rsaPublicKey?: string; + /** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */ + jwksUrl?: string; +} + +export interface JwtUtils { + createAccessToken(payload: { + sub: string; + email: string; + role: string; + productId?: string; + }): Promise; + createRefreshToken(payload: { sub: string; productId?: string }): Promise; + verifyToken(token: string): Promise; +} diff --git a/vendor/bytelyst/auth/tsconfig.json b/vendor/bytelyst/auth/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/auth/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/auth/vitest.config.ts b/vendor/bytelyst/auth/vitest.config.ts new file mode 100644 index 0000000..03ac558 --- /dev/null +++ b/vendor/bytelyst/auth/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + pool: 'forks', + testTimeout: 15_000, + }, +}); diff --git a/vendor/bytelyst/config/package.json b/vendor/bytelyst/config/package.json index 6aadbf9..433cbda 100644 --- a/vendor/bytelyst/config/package.json +++ b/vendor/bytelyst/config/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/config", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "exports": { ".": { diff --git a/vendor/bytelyst/config/src/__tests__/config.test.ts b/vendor/bytelyst/config/src/__tests__/config.test.ts new file mode 100644 index 0000000..ae64c6c --- /dev/null +++ b/vendor/bytelyst/config/src/__tests__/config.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { + baseEnvSchema, + loadConfig, + loadProductIdentity, + getProductId, + _resetProductIdentity, +} from '../index.js'; + +describe('baseEnvSchema', () => { + it('provides defaults for PORT, HOST, NODE_ENV, COSMOS_DATABASE', () => { + const result = baseEnvSchema.parse({ + SERVICE_NAME: 'test-svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }); + expect(result.PORT).toBe(3000); + expect(result.HOST).toBe('0.0.0.0'); + expect(result.NODE_ENV).toBe('development'); + expect(result.COSMOS_DATABASE).toBe('lysnrai'); + }); + + it('rejects missing SERVICE_NAME', () => { + expect(() => + baseEnvSchema.parse({ + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }) + ).toThrow(); + }); + + it('rejects missing COSMOS_ENDPOINT', () => { + expect(() => + baseEnvSchema.parse({ + SERVICE_NAME: 'svc', + COSMOS_KEY: 'key==', + }) + ).toThrow(); + }); + + it('rejects missing COSMOS_KEY', () => { + expect(() => + baseEnvSchema.parse({ + SERVICE_NAME: 'svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + }) + ).toThrow(); + }); + + it('coerces PORT from string', () => { + const result = baseEnvSchema.parse({ + PORT: '4003', + SERVICE_NAME: 'svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }); + expect(result.PORT).toBe(4003); + }); + + it('accepts valid NODE_ENV values', () => { + for (const env of ['development', 'production', 'test']) { + const result = baseEnvSchema.parse({ + NODE_ENV: env, + SERVICE_NAME: 'svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }); + expect(result.NODE_ENV).toBe(env); + } + }); + + it('rejects invalid NODE_ENV', () => { + expect(() => + baseEnvSchema.parse({ + NODE_ENV: 'staging', + SERVICE_NAME: 'svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }) + ).toThrow(); + }); +}); + +describe('loadConfig', () => { + const origEnv = process.env; + + beforeEach(() => { + process.env = { + ...origEnv, + SERVICE_NAME: 'test-svc', + COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', + COSMOS_KEY: 'key==', + }; + }); + + afterEach(() => { + process.env = origEnv; + }); + + it('parses base env without extension', () => { + const config = loadConfig(); + expect(config.SERVICE_NAME).toBe('test-svc'); + expect(config.PORT).toBe(3000); + }); + + it('extends with additional fields', async () => { + process.env.STRIPE_KEY = 'sk_test_123'; + const { z } = await import('zod'); + const config = loadConfig({ STRIPE_KEY: z.string().min(1) }); + expect(config.STRIPE_KEY).toBe('sk_test_123'); + expect(config.SERVICE_NAME).toBe('test-svc'); + }); + + it('throws on missing required extension field', async () => { + const { z } = await import('zod'); + expect(() => loadConfig({ MISSING_FIELD: z.string().min(1) })).toThrow(); + }); +}); + +describe('productIdentity', () => { + beforeEach(() => { + _resetProductIdentity(); + }); + + it('falls back to env vars', () => { + process.env.PRODUCT_ID = 'testprod'; + process.env.DISPLAY_NAME = 'TestProd'; + const identity = loadProductIdentity(); + expect(identity.productId).toBe('testprod'); + expect(identity.displayName).toBe('TestProd'); + delete process.env.PRODUCT_ID; + delete process.env.DISPLAY_NAME; + }); + + it('defaults to lysnrai when no env or file', () => { + delete process.env.PRODUCT_ID; + const identity = loadProductIdentity(); + expect(identity.productId).toBe('lysnrai'); + expect(identity.displayName).toBe('LysnrAI'); + expect(identity.licensePrefix).toBe('LYSNR'); + }); + + it('getProductId returns just the ID', () => { + _resetProductIdentity(); + delete process.env.PRODUCT_ID; + expect(getProductId()).toBe('lysnrai'); + }); + + it('caches identity after first load', () => { + delete process.env.PRODUCT_ID; + const id1 = loadProductIdentity(); + process.env.PRODUCT_ID = 'changed'; + const id2 = loadProductIdentity(); + expect(id1).toBe(id2); // same cached object + delete process.env.PRODUCT_ID; + }); + + it('_resetProductIdentity clears cache', () => { + delete process.env.PRODUCT_ID; + loadProductIdentity(); + _resetProductIdentity(); + process.env.PRODUCT_ID = 'newprod'; + const fresh = loadProductIdentity(); + expect(fresh.productId).toBe('newprod'); + delete process.env.PRODUCT_ID; + }); +}); diff --git a/vendor/bytelyst/config/src/__tests__/keyvault.test.ts b/vendor/bytelyst/config/src/__tests__/keyvault.test.ts new file mode 100644 index 0000000..659f193 --- /dev/null +++ b/vendor/bytelyst/config/src/__tests__/keyvault.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for Azure Key Vault secret resolution. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '../keyvault.js'; +import type { SecretMapping } from '../keyvault.js'; + +// Mock Azure SDK dynamic imports to prevent test timeouts +const { mockGetSecret } = vi.hoisted(() => { + const mockGetSecret = vi.fn(); + return { mockGetSecret }; +}); + +vi.mock('@azure/identity', () => ({ + DefaultAzureCredential: vi.fn(), +})); + +vi.mock('@azure/keyvault-secrets', () => ({ + SecretClient: vi.fn().mockImplementation(() => ({ + getSecret: mockGetSecret, + })), +})); + +describe('resolveKeyVaultSecrets', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clean env vars used in tests + delete process.env.AZURE_KEYVAULT_URL; + delete process.env.TEST_SECRET_A; + delete process.env.TEST_SECRET_B; + mockGetSecret.mockReset(); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.clearAllMocks(); + }); + + it('skips entirely when AZURE_KEYVAULT_URL is not set', async () => { + const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; + + await resolveKeyVaultSecrets(secrets); + + // Should not have set the env var (no KV to resolve from) + expect(process.env.TEST_SECRET_A).toBeUndefined(); + }); + + it('skips secrets that already exist in env', async () => { + process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; + process.env.TEST_SECRET_A = 'already-set'; + + const secrets: SecretMapping[] = [{ kvName: 'test-secret-a', envVar: 'TEST_SECRET_A' }]; + + // Should not attempt KV call since all secrets are present + await resolveKeyVaultSecrets(secrets); + + expect(process.env.TEST_SECRET_A).toBe('already-set'); + }); + + it('accepts custom vaultUrl via opts', async () => { + mockGetSecret.mockResolvedValue({ value: 'resolved-value' }); + + const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; + + await resolveKeyVaultSecrets(secrets, { vaultUrl: 'https://kv-test.vault.azure.net' }); + + expect(process.env.TEST_SECRET_A).toBe('resolved-value'); + expect(mockGetSecret).toHaveBeenCalledWith('test-secret'); + }); + + it('handles empty secrets array', async () => { + process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; + + await expect(resolveKeyVaultSecrets([])).resolves.not.toThrow(); + }); + + it('resolves multiple missing secrets from Key Vault', async () => { + process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; + mockGetSecret + .mockResolvedValueOnce({ value: 'secret-a-val' }) + .mockResolvedValueOnce({ value: 'secret-b-val' }); + + const secrets: SecretMapping[] = [ + { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, + { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, + ]; + + await resolveKeyVaultSecrets(secrets); + + expect(process.env.TEST_SECRET_A).toBe('secret-a-val'); + expect(process.env.TEST_SECRET_B).toBe('secret-b-val'); + }); + + it('warns but does not throw when getSecret fails', async () => { + process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; + mockGetSecret.mockRejectedValue(new Error('SecretNotFound')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const secrets: SecretMapping[] = [{ kvName: 'bad-secret', envVar: 'TEST_SECRET_A' }]; + + await resolveKeyVaultSecrets(secrets); + + expect(process.env.TEST_SECRET_A).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1/1 secrets failed')); + + warnSpy.mockRestore(); + }); + + it('filters to only missing secrets — skips already-present', async () => { + process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; + process.env.TEST_SECRET_A = 'present'; + mockGetSecret.mockResolvedValue({ value: 'from-kv' }); + + const secrets: SecretMapping[] = [ + { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, + { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, + ]; + + await resolveKeyVaultSecrets(secrets); + + // TEST_SECRET_A should remain unchanged (already present) + expect(process.env.TEST_SECRET_A).toBe('present'); + // TEST_SECRET_B should be resolved from KV + expect(process.env.TEST_SECRET_B).toBe('from-kv'); + // getSecret should only be called for the missing secret + expect(mockGetSecret).toHaveBeenCalledTimes(1); + expect(mockGetSecret).toHaveBeenCalledWith('secret-b'); + }); +}); + +describe('LYSNR_SECRETS', () => { + it('exports all expected secret mappings', () => { + const expectedKeys = [ + 'COSMOS_KEY', + 'COSMOS_ENDPOINT', + 'JWT_SECRET', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'BILLING_INTERNAL_KEY', + 'AZURE_BLOB_CONNECTION_STRING', + 'AZURE_BLOB_ACCOUNT_KEY', + 'GEMINI_API_KEY', + 'SEED_SECRET', + 'AZURE_SPEECH_KEY', + 'AZURE_OPENAI_KEY', + 'AZURE_OPENAI_ENDPOINT', + ]; + + for (const key of expectedKeys) { + expect(LYSNR_SECRETS).toHaveProperty(key); + } + }); + + it('each mapping has kvName and envVar', () => { + for (const [key, mapping] of Object.entries(LYSNR_SECRETS)) { + expect(mapping.kvName).toBeDefined(); + expect(typeof mapping.kvName).toBe('string'); + expect(mapping.kvName.length).toBeGreaterThan(0); + + expect(mapping.envVar).toBeDefined(); + expect(typeof mapping.envVar).toBe('string'); + expect(mapping.envVar).toBe(key); + } + }); + + it('kvNames follow lysnr-* naming convention', () => { + for (const mapping of Object.values(LYSNR_SECRETS)) { + expect(mapping.kvName).toMatch(/^lysnr-/); + } + }); + + it('envVars are UPPER_SNAKE_CASE', () => { + for (const mapping of Object.values(LYSNR_SECRETS)) { + expect(mapping.envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); + } + }); +}); diff --git a/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts b/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts new file mode 100644 index 0000000..b1abd84 --- /dev/null +++ b/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + ProductManifestSchema, + ExtendedProductManifestSchema, + PlatformSchema, + ThemeSchema, + ContainerDefSchema, + FeatureFlagSchema, + BundleIdSchema, + DEFAULT_THEME, + loadProductManifest, + loadProductManifestSync, + resolveTheme, + validateProductManifest, + safeValidateProductManifest, +} from '../index.js'; + +// ── Minimal valid manifest ────────────────────────────────────────────────── + +const MINIMAL = { + productId: 'testprod', + displayName: 'TestProd', +}; + +// ── Full manifest (matches FlowMonk-style) ────────────────────────────────── + +const FULL = { + productId: 'flowmonk', + displayName: 'FlowMonk', + name: 'FlowMonk', + tagline: 'Agent-first planning and execution', + domain: 'flowmonk.app', + backendPort: 4017, + primarySurface: 'web', + mobileCompanion: true, + platforms: ['web', 'ios', 'android'], + bundleIds: { + ios: 'com.saravana.flowmonk', + android: 'com.saravana.flowmonk', + web: 'flowmonk.app', + }, + appStore: { + category: 'Productivity', + subcategory: 'Task Management', + ageRating: '4+', + privacyUrl: 'https://flowmonk.app/privacy', + termsUrl: 'https://flowmonk.app/terms', + supportUrl: 'https://flowmonk.app/support', + }, + cosmos: { + containers: [ + { name: 'zones', partitionKey: '/userId' }, + { name: 'flows', partitionKey: '/userId' }, + { name: 'tasks', partitionKey: '/userId' }, + ], + }, + flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }], + ports: { service: 4017 }, + version: '0.1.0', +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProductManifestSchema', () => { + it('parses a minimal manifest (just productId + displayName)', () => { + const result = ProductManifestSchema.parse(MINIMAL); + expect(result.productId).toBe('testprod'); + expect(result.displayName).toBe('TestProd'); + expect(result.platforms).toEqual(['web']); // default + expect(result.flags).toEqual([]); // default + }); + + it('parses a full manifest', () => { + const result = ProductManifestSchema.parse(FULL); + expect(result.productId).toBe('flowmonk'); + expect(result.backendPort).toBe(4017); + expect(result.cosmos?.containers).toHaveLength(3); + expect(result.flags).toHaveLength(1); + expect(result.appStore?.category).toBe('Productivity'); + }); + + it('rejects missing productId', () => { + expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow(); + }); + + it('rejects missing displayName', () => { + expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow(); + }); + + it('rejects invalid productId (uppercase)', () => { + expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow( + /lowercase/ + ); + }); + + it('rejects productId starting with number', () => { + expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow(); + }); + + it('allows hyphens in productId', () => { + const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' }); + expect(result.productId).toBe('my-app'); + }); + + it('rejects backendPort below 1024', () => { + expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow(); + }); + + it('rejects backendPort above 65535', () => { + expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow(); + }); + + it('accepts valid backendPort', () => { + const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 }); + expect(result.backendPort).toBe(4016); + }); + + it('parses LysnrAI-style manifest (legacy fields)', () => { + const lysnrai = { + displayName: 'LysnrAI', + productId: 'lysnrai', + licensePrefix: 'LYSNR', + configDirName: '.LysnrAI', + envVarPrefix: 'LYSNR', + bundleIdSuffix: 'LysnrAI', + packageName: 'lysnrai', + }; + const result = ProductManifestSchema.parse(lysnrai); + expect(result.licensePrefix).toBe('LYSNR'); + expect(result.envVarPrefix).toBe('LYSNR'); + }); + + it('parses NoteLett-style manifest (bundleId as object)', () => { + const notelett = { + productId: 'notelett', + displayName: 'NoteLett', + bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, + backendPort: 4016, + appGroup: 'group.com.bytelyst.notelett', + }; + const result = ProductManifestSchema.parse(notelett); + expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }); + expect(result.appGroup).toBe('group.com.bytelyst.notelett'); + }); + + it('parses NomGap-style manifest (bundleId as string)', () => { + const nomgap = { + productId: 'nomgap', + displayName: 'NomGap', + bundleId: 'com.saravana.nomgap', + domain: 'nomgap.app', + }; + const result = ProductManifestSchema.parse(nomgap); + expect(result.bundleId).toBe('com.saravana.nomgap'); + }); + + it('parses ActionTrail-style manifest (no mobile)', () => { + const actiontrail = { + productId: 'actiontrail', + displayName: 'ActionTrail', + tagline: 'AI Activity Oversight', + domain: 'actiontrail.dev', + backendPort: 4018, + primarySurface: 'web', + mobileCompanion: false, + }; + const result = ProductManifestSchema.parse(actiontrail); + expect(result.mobileCompanion).toBe(false); + expect(result.primarySurface).toBe('web'); + }); +}); + +describe('duplicate container validation', () => { + it('rejects duplicate container names', () => { + const manifest = { + ...MINIMAL, + cosmos: { + containers: [ + { name: 'users', partitionKey: '/userId' }, + { name: 'users', partitionKey: '/email' }, + ], + }, + }; + expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/); + }); + + it('allows unique container names', () => { + const manifest = { + ...MINIMAL, + cosmos: { + containers: [ + { name: 'users', partitionKey: '/userId' }, + { name: 'sessions', partitionKey: '/userId' }, + ], + }, + }; + const result = ProductManifestSchema.parse(manifest); + expect(result.cosmos?.containers).toHaveLength(2); + }); + + it('allows single container (no duplicate check needed)', () => { + const manifest = { + ...MINIMAL, + cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] }, + }; + const result = ProductManifestSchema.parse(manifest); + expect(result.cosmos?.containers).toHaveLength(1); + }); +}); + +describe('ExtendedProductManifestSchema', () => { + it('allows unknown keys (passthrough)', () => { + const result = ExtendedProductManifestSchema.parse({ + ...MINIMAL, + customField: 'hello', + nestedCustom: { x: 1 }, + }); + expect((result as Record).customField).toBe('hello'); + }); +}); + +describe('PlatformSchema', () => { + it('accepts valid platforms', () => { + for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) { + expect(PlatformSchema.parse(p)).toBe(p); + } + }); + + it('rejects invalid platform', () => { + expect(() => PlatformSchema.parse('windows')).toThrow(); + }); +}); + +describe('ThemeSchema', () => { + it('validates hex colors', () => { + const result = ThemeSchema.parse(DEFAULT_THEME); + expect(result.primary).toBe('#5AE68C'); + }); + + it('rejects non-hex colors', () => { + expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/); + }); + + it('rejects 3-digit hex', () => { + expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow(); + }); +}); + +describe('ContainerDefSchema', () => { + it('parses valid container', () => { + const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' }); + expect(result.name).toBe('users'); + }); + + it('accepts optional ttlSeconds and uniqueKeys', () => { + const result = ContainerDefSchema.parse({ + name: 'sessions', + partitionKey: '/userId', + ttlSeconds: 86400, + uniqueKeys: ['/email'], + }); + expect(result.ttlSeconds).toBe(86400); + expect(result.uniqueKeys).toEqual(['/email']); + }); + + it('rejects empty name', () => { + expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow(); + }); + + it('rejects negative ttlSeconds', () => { + expect(() => + ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 }) + ).toThrow(); + }); +}); + +describe('FeatureFlagSchema', () => { + it('accepts boolean default', () => { + const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false }); + expect(result.defaultValue).toBe(false); + }); + + it('accepts string default', () => { + const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' }); + expect(result.defaultValue).toBe('free'); + }); + + it('accepts number default', () => { + const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 }); + expect(result.defaultValue).toBe(100); + }); + + it('rejects empty key', () => { + expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow(); + }); +}); + +describe('BundleIdSchema', () => { + it('accepts string bundle ID', () => { + expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app'); + }); + + it('accepts per-platform bundle IDs', () => { + const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' }); + expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' }); + }); + + it('rejects empty string', () => { + expect(() => BundleIdSchema.parse('')).toThrow(); + }); +}); + +describe('resolveTheme', () => { + it('returns defaults when no theme specified', () => { + const manifest = ProductManifestSchema.parse(MINIMAL); + const theme = resolveTheme(manifest); + expect(theme).toEqual(DEFAULT_THEME); + }); + + it('merges partial theme with defaults', () => { + const manifest = ProductManifestSchema.parse({ + ...MINIMAL, + theme: { primary: '#FF0000' }, + }); + const theme = resolveTheme(manifest); + expect(theme.primary).toBe('#FF0000'); + expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved + }); +}); + +describe('validateProductManifest', () => { + it('validates a valid object', () => { + const result = validateProductManifest(MINIMAL); + expect(result.productId).toBe('testprod'); + }); + + it('throws on invalid object', () => { + expect(() => validateProductManifest({ bad: true })).toThrow(); + }); +}); + +describe('safeValidateProductManifest', () => { + it('returns manifest on valid input', () => { + const result = safeValidateProductManifest(MINIMAL); + expect(result).not.toBeNull(); + expect(result!.productId).toBe('testprod'); + }); + + it('returns null on invalid input', () => { + const result = safeValidateProductManifest({ bad: true }); + expect(result).toBeNull(); + }); +}); + +describe('loadProductManifest / loadProductManifestSync', () => { + const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`); + const validPath = join(tmpDir, 'valid.json'); + const invalidPath = join(tmpDir, 'invalid.json'); + + beforeEach(() => { + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(validPath, JSON.stringify(FULL)); + writeFileSync(invalidPath, JSON.stringify({ bad: true })); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('loads and validates from file (async)', async () => { + const result = await loadProductManifest(validPath); + expect(result.productId).toBe('flowmonk'); + expect(result.cosmos?.containers).toHaveLength(3); + }); + + it('loads and validates from file (sync)', () => { + const result = loadProductManifestSync(validPath); + expect(result.productId).toBe('flowmonk'); + }); + + it('throws on invalid file (async)', async () => { + await expect(loadProductManifest(invalidPath)).rejects.toThrow(); + }); + + it('throws on invalid file (sync)', () => { + expect(() => loadProductManifestSync(invalidPath)).toThrow(); + }); + + it('throws on missing file (async)', async () => { + await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow(); + }); + + it('throws on missing file (sync)', () => { + expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow(); + }); +}); + +describe('real-world product.json files parse cleanly', () => { + it('parses LysnrAI product.json', () => { + const json = { + displayName: 'LysnrAI', + productId: 'lysnrai', + licensePrefix: 'LYSNR', + configDirName: '.LysnrAI', + envVarPrefix: 'LYSNR', + bundleIdSuffix: 'LysnrAI', + packageName: 'lysnrai', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses NomGap product.json', () => { + const json = { + productId: 'nomgap', + displayName: 'NomGap', + bundleId: 'com.saravana.nomgap', + domain: 'nomgap.app', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses NoteLett product.json', () => { + const json = { + productId: 'notelett', + displayName: 'NoteLett', + licensePrefix: 'NOTELETT', + configDirName: '.NoteLett', + envVarPrefix: 'NOTELETT', + bundleIdSuffix: 'notelett', + packageName: 'notelett', + domain: 'notelett.app', + bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, + appGroup: 'group.com.bytelyst.notelett', + backendPort: 4016, + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses FlowMonk product.json', () => { + expect(() => ProductManifestSchema.parse(FULL)).not.toThrow(); + }); + + it('parses ActionTrail product.json', () => { + const json = { + productId: 'actiontrail', + displayName: 'ActionTrail', + tagline: 'AI Activity Oversight', + domain: 'actiontrail.dev', + backendPort: 4018, + primarySurface: 'web', + mobileCompanion: false, + bundleIds: { web: 'actiontrail.dev' }, + appStore: { + category: 'Developer Tools', + subcategory: 'AI Monitoring', + privacyUrl: 'https://actiontrail.dev/privacy', + termsUrl: 'https://actiontrail.dev/terms', + supportUrl: 'https://actiontrail.dev/support', + }, + version: '0.1.0', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); +}); diff --git a/vendor/bytelyst/config/src/base-schema.ts b/vendor/bytelyst/config/src/base-schema.ts new file mode 100644 index 0000000..89bf02a --- /dev/null +++ b/vendor/bytelyst/config/src/base-schema.ts @@ -0,0 +1,19 @@ +/** + * Base environment schema shared by all Fastify microservices. + * Each service extends this with its own fields via loadConfig(). + */ + +import { z } from 'zod'; + +export const baseEnvSchema = 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(), + COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'), + COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'), + COSMOS_DATABASE: z.string().default('lysnrai'), +}); + +export type BaseEnv = z.infer; diff --git a/vendor/bytelyst/config/src/index.ts b/vendor/bytelyst/config/src/index.ts new file mode 100644 index 0000000..1184ecb --- /dev/null +++ b/vendor/bytelyst/config/src/index.ts @@ -0,0 +1,38 @@ +export { baseEnvSchema, type BaseEnv } from './base-schema.js'; +export { loadConfig } from './loader.js'; +export { + loadProductIdentity, + getProductId, + _resetProductIdentity, + type ProductIdentity, +} from './product-identity.js'; +export { + resolveSecrets, + resolveKeyVaultSecrets, + LYSNR_SECRETS, + type SecretMapping, + type SecretsProviderType, +} from './keyvault.js'; +export { + ProductManifestSchema, + PlatformSchema, + ThemeSchema, + ContainerDefSchema, + FeatureFlagSchema, + PortConfigSchema, + BundleIdSchema, + AppStoreSchema, + ExtendedProductManifestSchema, + DEFAULT_THEME, + loadProductManifest, + loadProductManifestSync, + resolveTheme, + validateProductManifest, + safeValidateProductManifest, + type ProductManifest, + type Platform, + type Theme, + type ContainerDef, + type FeatureFlag, + type PortConfig, +} from './product-manifest.js'; diff --git a/vendor/bytelyst/config/src/keyvault.ts b/vendor/bytelyst/config/src/keyvault.ts new file mode 100644 index 0000000..18db0c9 --- /dev/null +++ b/vendor/bytelyst/config/src/keyvault.ts @@ -0,0 +1,136 @@ +/** + * Cloud-agnostic secret resolution for Node.js services + dashboards. + * + * Call resolveSecrets() BEFORE Zod config parsing to populate + * process.env from a secrets provider. Falls back gracefully when unavailable. + * + * Provider selection via SECRETS_PROVIDER env var: + * - 'azure-keyvault' (default if AZURE_KEYVAULT_URL is set) — Azure Key Vault + * - 'env' (default if no vault URL) — do nothing, use .env values as-is + * + * Backward compatible: resolveKeyVaultSecrets() still works identically. + */ + +export type SecretsProviderType = 'azure-keyvault' | 'env'; + +export interface SecretMapping { + /** Provider-specific secret name (e.g. 'lysnr-cosmos-key' for AKV) */ + kvName: string; + /** Environment variable name to populate (e.g. 'COSMOS_KEY') */ + envVar: string; +} + +/** + * Resolve which secrets provider to use. + */ +function resolveSecretsProvider(): SecretsProviderType { + const explicit = (process.env.SECRETS_PROVIDER || '').toLowerCase(); + if (explicit === 'azure-keyvault' || explicit === 'azure') return 'azure-keyvault'; + if (explicit === 'env') return 'env'; + + // Auto-detect: use AKV if AZURE_KEYVAULT_URL is set + if (process.env.AZURE_KEYVAULT_URL) return 'azure-keyvault'; + return 'env'; +} + +/** + * Cloud-agnostic secret resolution into process.env. + * + * - Only fetches secrets whose env var is empty/unset (env takes precedence). + * - Skips entirely if provider is 'env' or no vault is configured. + * - Logs warnings but does NOT throw — services fall back to .env values. + * + * @param secrets - Array of {kvName, envVar} mappings + * @param opts - Optional overrides + */ +export async function resolveSecrets( + secrets: SecretMapping[], + opts?: { vaultUrl?: string; provider?: SecretsProviderType } +): Promise { + const provider = opts?.provider ?? resolveSecretsProvider(); + + if (provider === 'env') return; // Nothing to resolve — use env vars as-is + + if (provider === 'azure-keyvault') { + return resolveAzureKeyVaultSecrets(secrets, opts); + } +} + +/** + * Resolve secrets from Azure Key Vault into process.env. + * @deprecated Use resolveSecrets() instead — this is kept for backward compatibility. + */ +export async function resolveKeyVaultSecrets( + secrets: SecretMapping[], + opts?: { vaultUrl?: string } +): Promise { + return resolveAzureKeyVaultSecrets(secrets, opts); +} + +/** + * Azure Key Vault implementation. + */ +async function resolveAzureKeyVaultSecrets( + secrets: SecretMapping[], + opts?: { vaultUrl?: string } +): Promise { + const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; + if (!vaultUrl) return; // No KV configured — use env vars as-is + + // Filter to only secrets that are missing from env + const missing = secrets.filter(s => !process.env[s.envVar]); + if (missing.length === 0) return; // All secrets already in env + + try { + const { DefaultAzureCredential } = await import('@azure/identity'); + const { SecretClient } = await import('@azure/keyvault-secrets'); + + const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); + + const results = await Promise.allSettled( + missing.map(async s => { + const secret = await client.getSecret(s.kvName); + if (secret.value) { + process.env[s.envVar] = secret.value; + } + }) + ); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. + console.warn( + `[secrets] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars` + ); + } + } catch { + // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. + console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); + } +} + +/** + * Standard secret mappings used across all LysnrAI services. + * Services pick the subset they need. + */ +export const LYSNR_SECRETS = { + COSMOS_KEY: { kvName: 'lysnr-cosmos-key', envVar: 'COSMOS_KEY' }, + COSMOS_ENDPOINT: { kvName: 'lysnr-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, + JWT_SECRET: { kvName: 'lysnr-jwt-secret', envVar: 'JWT_SECRET' }, + STRIPE_SECRET_KEY: { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, + STRIPE_WEBHOOK_SECRET: { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, + BILLING_INTERNAL_KEY: { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, + AZURE_BLOB_CONNECTION_STRING: { + kvName: 'lysnr-blob-connection-string', + envVar: 'AZURE_BLOB_CONNECTION_STRING', + }, + AZURE_BLOB_ACCOUNT_KEY: { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, + GEMINI_API_KEY: { kvName: 'lysnr-gemini-api-key', envVar: 'GEMINI_API_KEY' }, + SEED_SECRET: { kvName: 'lysnr-seed-secret', envVar: 'SEED_SECRET' }, + AZURE_SPEECH_KEY: { kvName: 'lysnr-azure-speech-key', envVar: 'AZURE_SPEECH_KEY' }, + AZURE_OPENAI_KEY: { kvName: 'lysnr-azure-openai-key', envVar: 'AZURE_OPENAI_KEY' }, + AZURE_OPENAI_ENDPOINT: { + kvName: 'lysnr-azure-openai-endpoint', + envVar: 'AZURE_OPENAI_ENDPOINT', + }, +} as const satisfies Record; diff --git a/vendor/bytelyst/config/src/loader.ts b/vendor/bytelyst/config/src/loader.ts new file mode 100644 index 0000000..3ede866 --- /dev/null +++ b/vendor/bytelyst/config/src/loader.ts @@ -0,0 +1,26 @@ +/** + * Config loader — parses process.env against the base schema + any extensions. + */ + +import { type ZodRawShape, z } from 'zod'; +import { baseEnvSchema } from './base-schema.js'; + +/** + * Load and validate environment configuration. + * + * @param extension - Additional Zod fields specific to this service + * @returns Parsed and validated config object + * + * @example + * ```ts + * const config = loadConfig({ + * STRIPE_SECRET_KEY: z.string().min(1), + * BILLING_INTERNAL_KEY: z.string().optional(), + * }); + * ``` + */ +export function loadConfig(extension?: T) { + const schema = extension ? baseEnvSchema.extend(extension) : baseEnvSchema; + return schema.parse(process.env) as z.infer & + (T extends ZodRawShape ? z.infer> : Record); +} diff --git a/vendor/bytelyst/config/src/product-identity.ts b/vendor/bytelyst/config/src/product-identity.ts new file mode 100644 index 0000000..1b961cc --- /dev/null +++ b/vendor/bytelyst/config/src/product-identity.ts @@ -0,0 +1,75 @@ +/** + * Product identity — reads from a product.json file or falls back to env vars. + * Eliminates the need for hardcoded product-config.ts files in every service. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export interface ProductIdentity { + productId: string; + displayName: string; + licensePrefix: string; + configDirName: string; + envVarPrefix: string; + bundleIdSuffix: string; + packageName: string; +} + +let _cached: ProductIdentity | null = null; + +/** + * Load product identity from a JSON file or environment variables. + * + * @param jsonPath - Path to product.json (optional, tries common locations) + * @returns Product identity object + */ +export function loadProductIdentity(jsonPath?: string): ProductIdentity { + if (_cached) return _cached; + + // Try loading from file + const paths = jsonPath + ? [jsonPath] + : [ + resolve('shared/product.json'), + resolve('../shared/product.json'), + resolve('../../shared/product.json'), + ]; + + for (const p of paths) { + try { + const raw = readFileSync(p, 'utf-8'); + _cached = JSON.parse(raw) as ProductIdentity; + return _cached; + } catch { + // Try next path + } + } + + // Fallback to env vars / defaults + _cached = { + productId: process.env.PRODUCT_ID || 'lysnrai', + displayName: process.env.DISPLAY_NAME || 'LysnrAI', + licensePrefix: process.env.LICENSE_PREFIX || 'LYSNR', + configDirName: process.env.CONFIG_DIR_NAME || '.LysnrAI', + envVarPrefix: process.env.ENV_VAR_PREFIX || 'LYSNR', + bundleIdSuffix: process.env.BUNDLE_ID_SUFFIX || 'LysnrAI', + packageName: process.env.PACKAGE_NAME || 'lysnrai', + }; + return _cached; +} + +/** + * Convenience: get just the product ID string. + */ +export function getProductId(): string { + return loadProductIdentity().productId; +} + +/** + * Reset the cache (useful for testing). + * @internal + */ +export function _resetProductIdentity(): void { + _cached = null; +} diff --git a/vendor/bytelyst/config/src/product-manifest.ts b/vendor/bytelyst/config/src/product-manifest.ts new file mode 100644 index 0000000..3598204 --- /dev/null +++ b/vendor/bytelyst/config/src/product-manifest.ts @@ -0,0 +1,305 @@ +/** + * Product Manifest Specification + * + * Defines the JSON schema for product.json files that capture everything + * about a ByteLyst product — identity, theme, features, containers, flags, ports. + * + * @module @bytelyst/config/product-manifest + */ + +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { z } from 'zod'; + +/** + * Platform identifiers + */ +export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch', 'mac']); + +/** + * Theme color token + */ +export const ColorTokenSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, { + message: 'Color must be a hex value like #5AE68C', +}); + +/** + * Theme specification + */ +export const ThemeSchema = z.object({ + primary: ColorTokenSchema, + secondary: ColorTokenSchema, + accent: ColorTokenSchema, + background: ColorTokenSchema, + surface: ColorTokenSchema, + text: ColorTokenSchema, + error: ColorTokenSchema, + warning: ColorTokenSchema, + success: ColorTokenSchema, +}); + +/** + * Cosmos container definition + */ +export const ContainerDefSchema = z.object({ + name: z.string().min(1), + partitionKey: z.string().min(1), + ttlSeconds: z.number().positive().optional(), + uniqueKeys: z.array(z.string()).optional(), +}); + +/** + * Feature flag default + */ +export const FeatureFlagSchema = z.object({ + key: z.string().min(1), + defaultValue: z.union([z.boolean(), z.string(), z.number()]), + description: z.string().optional(), +}); + +/** + * Port configuration + */ +export const PortConfigSchema = z.object({ + service: z.number().optional(), + dashboard: z.number().optional(), + web: z.number().optional(), +}); + +/** + * Bundle ID — either a single reverse-DNS string or per-platform object + */ +export const BundleIdSchema = z.union([ + z.string().min(1), + z.object({ + ios: z.string().optional(), + android: z.string().optional(), + web: z.string().optional(), + }), +]); + +/** + * App store metadata (optional) + */ +export const AppStoreSchema = z + .object({ + category: z.string().optional(), + subcategory: z.string().optional(), + ageRating: z.string().optional(), + privacyUrl: z.string().url().optional(), + termsUrl: z.string().url().optional(), + supportUrl: z.string().url().optional(), + }) + .optional(); + +/** + * Product manifest schema (Zod) + * + * Designed to accommodate the real-world variety of product.json files + * across the ByteLyst ecosystem. All products use `productId` as the + * primary identifier. + * + * Example product.json: + * ```json + * { + * "productId": "lysnrai", + * "displayName": "LysnrAI", + * "bundleId": "com.saravana.lysnrai", + * "domain": "lysnrai.app", + * "description": "Voice-to-text dictation platform", + * "backendPort": 4015, + * "platforms": ["web", "ios", "android", "desktop"], + * "cosmos": { + * "containers": [ + * { "name": "transcripts", "partitionKey": "/userId" }, + * { "name": "sessions", "partitionKey": "/userId" } + * ] + * }, + * "ports": { + * "service": 4015, + * "dashboard": 3002 + * } + * } + * ``` + */ +const BaseManifestSchema = z.object({ + // Identity (productId required, rest optional for minimal manifests) + productId: z.string().regex(/^[a-z][a-z0-9-]*$/, { + message: 'Product ID must be lowercase alphanumeric/hyphens, starting with letter', + }), + displayName: z.string().min(1).max(50), + + // Optional identity fields + name: z.string().min(1).max(50).optional(), + bundleId: BundleIdSchema.optional(), + domain: z.string().optional(), + tagline: z.string().max(200).optional(), + description: z.string().max(500).optional(), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional(), + + // Platforms (defaults to web) + platforms: z.array(PlatformSchema).default(['web']), + primarySurface: PlatformSchema.optional(), + mobileCompanion: z.boolean().optional(), + + // Backend port (convenience — also available in ports.service) + backendPort: z.number().min(1024).max(65535).optional(), + + // Legacy identity fields from older product.json files + licensePrefix: z.string().optional(), + configDirName: z.string().optional(), + envVarPrefix: z.string().optional(), + bundleIdSuffix: z.string().optional(), + packageName: z.string().optional(), + appGroup: z.string().optional(), + + // Per-platform bundle IDs (alternative to bundleId) + bundleIds: z.record(z.string(), z.string()).optional(), + + // App store metadata + appStore: AppStoreSchema, + + // Theming (optional, uses defaults if not specified) + theme: ThemeSchema.partial().optional(), + + // Feature map (key → boolean/string/number) + features: z.record(z.string(), z.boolean().or(z.string()).or(z.number())).optional(), + + // Cosmos containers + cosmos: z + .object({ + containers: z.array(ContainerDefSchema).default([]), + }) + .optional(), + + // Feature flags with defaults + flags: z.array(FeatureFlagSchema).default([]), + + // Port configuration + ports: PortConfigSchema.optional(), + + // Agent/AI fields (used by FlowMonk, ActionTrail) + backendAuthority: z.string().optional(), + planningEngine: z.string().optional(), + aiRole: z.array(z.string()).optional(), +}); + +export const ProductManifestSchema = BaseManifestSchema.superRefine((data, ctx) => { + // Validate no duplicate container names + const containers = data.cosmos?.containers; + if (containers && containers.length > 1) { + const names = containers.map(c => c.name); + const seen = new Set(); + for (let i = 0; i < names.length; i++) { + if (seen.has(names[i])) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate container name: ${names[i]}`, + path: ['cosmos', 'containers', i, 'name'], + }); + } + seen.add(names[i]); + } + } +}); + +/** + * Extended manifest that allows additional unknown keys. + * Use this when you need to access custom fields not in the schema. + */ +export const ExtendedProductManifestSchema = BaseManifestSchema.passthrough(); + +/** + * Inferred TypeScript type for ProductManifest + */ +export type ProductManifest = z.infer; +export type Platform = z.infer; +export type Theme = z.infer; +export type ContainerDef = z.infer; +export type FeatureFlag = z.infer; +export type PortConfig = z.infer; + +/** + * Default theme colors (ByteLyst brand palette) + */ +export const DEFAULT_THEME: Theme = { + primary: '#5AE68C', + secondary: '#5A8CFF', + accent: '#2EE6D6', + background: '#06070A', + surface: '#121725', + text: '#EFF4FF', + error: '#FF6E6E', + warning: '#F59E0B', + success: '#34D399', +}; + +/** + * Load and validate a product manifest from a file path + * + * @param path Path to product.json file + * @returns Validated ProductManifest + * @throws ZodError if validation fails + * + * @example + * ```ts + * const manifest = await loadProductManifest('./product.json'); + * console.log(manifest.productId); // 'lysnrai' + * ``` + */ +export async function loadProductManifest(path: string): Promise { + const content = await readFile(path, 'utf-8'); + const json = JSON.parse(content); + return ProductManifestSchema.parse(json); +} + +/** + * Synchronous version of loadProductManifest (for startup use) + * + * @param path Path to product.json file + * @returns Validated ProductManifest + * @throws ZodError if validation fails + */ +export function loadProductManifestSync(path: string): ProductManifest { + const content = readFileSync(path, 'utf-8'); + const json = JSON.parse(content); + return ProductManifestSchema.parse(json); +} + +/** + * Merge manifest theme with defaults + * + * @param manifest Product manifest + * @returns Complete theme with defaults filled in + */ +export function resolveTheme(manifest: ProductManifest): Theme { + return { + ...DEFAULT_THEME, + ...manifest.theme, + }; +} + +/** + * Validate a product manifest object without loading from file + * + * @param json Parsed JSON object + * @returns Validated ProductManifest + * @throws ZodError if validation fails + */ +export function validateProductManifest(json: unknown): ProductManifest { + return ProductManifestSchema.parse(json); +} + +/** + * Safe validation that returns null on failure + * + * @param json Parsed JSON object + * @returns Validated ProductManifest or null + */ +export function safeValidateProductManifest(json: unknown): ProductManifest | null { + const result = ProductManifestSchema.safeParse(json); + return result.success ? result.data : null; +} diff --git a/vendor/bytelyst/config/tsconfig.json b/vendor/bytelyst/config/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/config/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/cosmos/package.json b/vendor/bytelyst/cosmos/package.json index 3bd24c9..838e959 100644 --- a/vendor/bytelyst/cosmos/package.json +++ b/vendor/bytelyst/cosmos/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/cosmos", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "exports": { ".": { diff --git a/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts b/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts new file mode 100644 index 0000000..46107e5 --- /dev/null +++ b/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +// Must hoist mocks so they're available when vi.mock factory runs +const { mockDatabase, mockDatabases, MockCosmosClient } = vi.hoisted(() => { + const mockContainer = { id: 'test-container' }; + const mockDatabase = { + container: vi.fn(() => mockContainer), + containers: { createIfNotExists: vi.fn() }, + }; + const mockDatabases = { createIfNotExists: vi.fn(() => ({ database: mockDatabase })) }; + const MockCosmosClient = vi.fn(() => ({ + database: vi.fn(() => mockDatabase), + databases: mockDatabases, + })); + return { mockDatabase, mockDatabases, MockCosmosClient }; +}); + +vi.mock('@azure/cosmos', () => ({ + CosmosClient: MockCosmosClient, + PartitionKeyDefinition: class {}, +})); + +import { + getCosmosClient, + getDatabase, + getContainer, + _resetClient, + registerContainers, + getRegisteredContainer, + initializeAllContainers, + _resetRegistry, +} from '../index.js'; + +describe('cosmos client', () => { + beforeEach(() => { + _resetClient(); + _resetRegistry(); + MockCosmosClient.mockClear(); + process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; + process.env.COSMOS_KEY = 'test-key=='; + process.env.COSMOS_DATABASE = 'testdb'; + }); + + afterEach(() => { + delete process.env.COSMOS_ENDPOINT; + delete process.env.COSMOS_KEY; + delete process.env.COSMOS_DATABASE; + }); + + it('getCosmosClient creates singleton', () => { + const c1 = getCosmosClient(); + const c2 = getCosmosClient(); + expect(c1).toBe(c2); + expect(MockCosmosClient).toHaveBeenCalledTimes(1); + expect(MockCosmosClient).toHaveBeenCalledWith({ + endpoint: 'https://test.documents.azure.com:443/', + key: 'test-key==', + }); + }); + + it('getCosmosClient throws without COSMOS_ENDPOINT', () => { + delete process.env.COSMOS_ENDPOINT; + expect(() => getCosmosClient()).toThrow('COSMOS_ENDPOINT is required'); + }); + + it('getCosmosClient throws without COSMOS_KEY', () => { + delete process.env.COSMOS_KEY; + expect(() => getCosmosClient()).toThrow('COSMOS_KEY is required'); + }); + + it('getDatabase uses COSMOS_DATABASE env var', () => { + const db = getDatabase(); + expect(db).toBeDefined(); + }); + + it('getDatabase defaults to lysnrai', () => { + _resetClient(); + delete process.env.COSMOS_DATABASE; + getDatabase(); + // Client was called, database accessed + expect(MockCosmosClient).toHaveBeenCalled(); + }); + + it('getContainer returns container by name', () => { + const c = getContainer('users'); + expect(c).toBeDefined(); + }); + + it('_resetClient clears singleton', () => { + getCosmosClient(); + expect(MockCosmosClient).toHaveBeenCalledTimes(1); + _resetClient(); + getCosmosClient(); + expect(MockCosmosClient).toHaveBeenCalledTimes(2); + }); +}); + +describe('container registry', () => { + beforeEach(() => { + _resetClient(); + _resetRegistry(); + MockCosmosClient.mockClear(); + process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; + process.env.COSMOS_KEY = 'test-key=='; + process.env.COSMOS_DATABASE = 'testdb'; + }); + + afterEach(() => { + delete process.env.COSMOS_ENDPOINT; + delete process.env.COSMOS_KEY; + delete process.env.COSMOS_DATABASE; + }); + + it('registerContainers stores definitions', () => { + registerContainers({ + users: { partitionKeyPath: '/productId' }, + tokens: { partitionKeyPath: '/userId' }, + }); + // Should not throw + const c = getRegisteredContainer('users'); + expect(c).toBeDefined(); + }); + + it('getRegisteredContainer throws for unknown name', () => { + expect(() => getRegisteredContainer('nope')).toThrow("Unknown container 'nope'"); + }); + + it('getRegisteredContainer caches container instances', () => { + registerContainers({ items: { partitionKeyPath: '/id' } }); + const c1 = getRegisteredContainer('items'); + const c2 = getRegisteredContainer('items'); + expect(c1).toBe(c2); + }); + + it('initializeAllContainers creates database and containers', async () => { + registerContainers({ + users: { partitionKeyPath: '/productId' }, + audit: { partitionKeyPath: '/productId', defaultTtl: 86400 }, + }); + + await initializeAllContainers(); + + expect(mockDatabases.createIfNotExists).toHaveBeenCalledWith({ id: 'testdb' }); + expect(mockDatabase.containers.createIfNotExists).toHaveBeenCalledTimes(2); + }); + + it('_resetRegistry clears all', () => { + registerContainers({ x: { partitionKeyPath: '/id' } }); + _resetRegistry(); + expect(() => getRegisteredContainer('x')).toThrow("Unknown container 'x'"); + }); +}); diff --git a/vendor/bytelyst/cosmos/src/client.ts b/vendor/bytelyst/cosmos/src/client.ts new file mode 100644 index 0000000..fdd68e1 --- /dev/null +++ b/vendor/bytelyst/cosmos/src/client.ts @@ -0,0 +1,54 @@ +/** + * Azure Cosmos DB client singleton. + * + * Reads COSMOS_ENDPOINT, COSMOS_KEY, and COSMOS_DATABASE from process.env. + * Provides getCosmosClient(), getDatabase(), and getContainer() for simple usage. + */ + +import { Container, CosmosClient, Database } from '@azure/cosmos'; + +let _client: CosmosClient | null = null; +let _database: Database | null = null; + +function getEnvOrThrow(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Environment variable ${name} is required`); + } + return value; +} + +export function getCosmosClient(): CosmosClient { + if (!_client) { + _client = new CosmosClient({ + endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), + key: getEnvOrThrow('COSMOS_KEY'), + }); + } + return _client; +} + +export function getDatabase(): Database { + if (!_database) { + const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; + _database = getCosmosClient().database(dbId); + } + return _database; +} + +/** + * Get a container by name. Uses the singleton database. + * For simple services that don't need a container registry. + */ +export function getContainer(name: string): Container { + return getDatabase().container(name); +} + +/** + * Reset the singleton (useful for testing). + * @internal + */ +export function _resetClient(): void { + _client = null; + _database = null; +} diff --git a/vendor/bytelyst/cosmos/src/containers.ts b/vendor/bytelyst/cosmos/src/containers.ts new file mode 100644 index 0000000..acd009d --- /dev/null +++ b/vendor/bytelyst/cosmos/src/containers.ts @@ -0,0 +1,125 @@ +/** + * Container registry for dashboards that need partition key validation + * and createIfNotExists support. + */ + +import { Container, PartitionKeyDefinition, type Database } from '@azure/cosmos'; +import { getCosmosClient, getDatabase } from './client.js'; +import type { ContainerConfig } from './types.js'; + +const _registry: Map = new Map(); +const _containerCache: Map = new Map(); + +/** + * Register containers with their partition key configuration. + * Call once at app startup before any getRegisteredContainer() calls. + */ +export function registerContainers(definitions: Record): void { + for (const [name, config] of Object.entries(definitions)) { + _registry.set(name, config); + } +} + +/** + * Get a container that was previously registered. + * Throws if the container name is unknown. + */ +export function getRegisteredContainer(name: string): Container { + if (!_registry.has(name)) { + throw new Error(`Unknown container '${name}'. Valid: ${[..._registry.keys()].join(', ')}`); + } + + let container = _containerCache.get(name); + if (!container) { + container = getDatabase().container(name); + _containerCache.set(name, container); + } + return container; +} + +/** + * Create all registered containers if they don't exist. + * Call from a seed script or on first deploy. + */ +export async function initializeAllContainers(): Promise { + const client = getCosmosClient(); + const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; + const database = await createDatabaseSafe(client, dbId); + + for (const [name, config] of _registry.entries()) { + await createContainerSafe(database, name, config); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => globalThis.setTimeout(resolve, ms)); +} + +function isCosmosConflict(err: unknown): boolean { + const e = err as { code?: number; statusCode?: number; message?: string } | null; + if (!e) return false; + if (e.code === 409 || e.statusCode === 409) return true; + return (e.message || '').toLowerCase().includes('already exists'); +} + +function isCosmosNotFound(err: unknown): boolean { + const e = err as { code?: number; statusCode?: number; message?: string } | null; + if (!e) return false; + if (e.code === 404 || e.statusCode === 404) return true; + return (e.message || '').toLowerCase().includes('not found'); +} + +async function createDatabaseSafe( + client: ReturnType, + dbId: string +): Promise { + try { + const { database } = await client.databases.createIfNotExists({ id: dbId }); + return database; + } catch (err) { + // createIfNotExists is not atomic; concurrent create can race and throw a conflict. + if (isCosmosConflict(err)) return client.database(dbId); + throw err; + } +} + +async function createContainerSafe( + database: Database, + name: string, + config: ContainerConfig +): Promise { + const payload = { + id: name, + partitionKey: { + paths: [config.partitionKeyPath], + kind: 'Hash', + } as PartitionKeyDefinition, + ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), + }; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await database.containers.createIfNotExists(payload); + return; + } catch (err) { + if (isCosmosConflict(err)) return; // Container was created by another process. + + // Sometimes the database/container metadata isn't immediately visible after creation. + if (isCosmosNotFound(err) && attempt < 2) { + await sleep(250 * (attempt + 1)); + continue; + } + + throw err; + } + } +} + +/** + * Reset the registry (useful for testing). + * @internal + */ +export function _resetRegistry(): void { + _registry.clear(); + _containerCache.clear(); +} diff --git a/vendor/bytelyst/cosmos/src/index.ts b/vendor/bytelyst/cosmos/src/index.ts new file mode 100644 index 0000000..49e2fce --- /dev/null +++ b/vendor/bytelyst/cosmos/src/index.ts @@ -0,0 +1,8 @@ +export { getCosmosClient, getDatabase, getContainer, _resetClient } from './client.js'; +export { + registerContainers, + getRegisteredContainer, + initializeAllContainers, + _resetRegistry, +} from './containers.js'; +export type { ContainerConfig } from './types.js'; diff --git a/vendor/bytelyst/cosmos/src/types.ts b/vendor/bytelyst/cosmos/src/types.ts new file mode 100644 index 0000000..59658f6 --- /dev/null +++ b/vendor/bytelyst/cosmos/src/types.ts @@ -0,0 +1,4 @@ +export interface ContainerConfig { + partitionKeyPath: string; + defaultTtl?: number | null; +} diff --git a/vendor/bytelyst/cosmos/tsconfig.json b/vendor/bytelyst/cosmos/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/cosmos/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/errors/package.json b/vendor/bytelyst/errors/package.json index cdd302b..1782252 100644 --- a/vendor/bytelyst/errors/package.json +++ b/vendor/bytelyst/errors/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/errors", - "version": "0.1.0", + "version": "0.1.6", "type": "module", "exports": { ".": { diff --git a/vendor/bytelyst/errors/src/__tests__/errors.test.ts b/vendor/bytelyst/errors/src/__tests__/errors.test.ts new file mode 100644 index 0000000..b9aa6a0 --- /dev/null +++ b/vendor/bytelyst/errors/src/__tests__/errors.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + BadRequestError, + ConflictError, + ForbiddenError, + NotFoundError, + ServiceError, + TooManyRequestsError, + UnauthorizedError, +} from '../index.js'; + +describe('ServiceError', () => { + it('sets statusCode and message', () => { + const err = new ServiceError(500, 'boom'); + expect(err.statusCode).toBe(500); + expect(err.message).toBe('boom'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ServiceError); + }); + + it('supports optional details', () => { + const err = new ServiceError(500, 'boom', { field: 'name' }); + expect(err.details).toEqual({ field: 'name' }); + }); +}); + +describe('HTTP error classes', () => { + const cases: [string, new () => ServiceError, number, string][] = [ + ['BadRequestError', BadRequestError, 400, 'Bad request'], + ['UnauthorizedError', UnauthorizedError, 401, 'Unauthorized'], + ['ForbiddenError', ForbiddenError, 403, 'Forbidden'], + ['NotFoundError', NotFoundError, 404, 'Not found'], + ['ConflictError', ConflictError, 409, 'Conflict'], + ['TooManyRequestsError', TooManyRequestsError, 429, 'Too many requests'], + ]; + + for (const [name, Ctor, expectedStatus, expectedMessage] of cases) { + it(`${name} has status ${expectedStatus}`, () => { + const err = new Ctor(); + expect(err.statusCode).toBe(expectedStatus); + expect(err.message).toBe(expectedMessage); + expect(err).toBeInstanceOf(ServiceError); + }); + } + + it('accepts custom message', () => { + const err = new NotFoundError('User not found'); + expect(err.message).toBe('User not found'); + expect(err.statusCode).toBe(404); + }); + + it('accepts details', () => { + const err = new TooManyRequestsError('Rate limited', { retryAfter: 60 }); + expect(err.details).toEqual({ retryAfter: 60 }); + }); +}); diff --git a/vendor/bytelyst/errors/src/http-errors.ts b/vendor/bytelyst/errors/src/http-errors.ts new file mode 100644 index 0000000..3638046 --- /dev/null +++ b/vendor/bytelyst/errors/src/http-errors.ts @@ -0,0 +1,37 @@ +import { ServiceError } from './service-error.js'; + +export class BadRequestError extends ServiceError { + constructor(message = 'Bad request', details?: Record) { + super(400, message, details); + } +} + +export class UnauthorizedError extends ServiceError { + constructor(message = 'Unauthorized', details?: Record) { + super(401, message, details); + } +} + +export class ForbiddenError extends ServiceError { + constructor(message = 'Forbidden', details?: Record) { + super(403, message, details); + } +} + +export class NotFoundError extends ServiceError { + constructor(message = 'Not found', details?: Record) { + super(404, message, details); + } +} + +export class ConflictError extends ServiceError { + constructor(message = 'Conflict', details?: Record) { + super(409, message, details); + } +} + +export class TooManyRequestsError extends ServiceError { + constructor(message = 'Too many requests', details?: Record) { + super(429, message, details); + } +} diff --git a/vendor/bytelyst/errors/src/index.ts b/vendor/bytelyst/errors/src/index.ts new file mode 100644 index 0000000..2464537 --- /dev/null +++ b/vendor/bytelyst/errors/src/index.ts @@ -0,0 +1,9 @@ +export { ServiceError } from './service-error.js'; +export { + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + TooManyRequestsError, +} from './http-errors.js'; diff --git a/vendor/bytelyst/errors/src/service-error.ts b/vendor/bytelyst/errors/src/service-error.ts new file mode 100644 index 0000000..1bc957f --- /dev/null +++ b/vendor/bytelyst/errors/src/service-error.ts @@ -0,0 +1,14 @@ +/** + * Base error class for typed HTTP service errors. + * All specific error types extend this class. + */ +export class ServiceError extends Error { + constructor( + public statusCode: number, + message: string, + public details?: Record + ) { + super(message); + this.name = 'ServiceError'; + } +} diff --git a/vendor/bytelyst/errors/tsconfig.json b/vendor/bytelyst/errors/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/errors/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/kill-switch-client/package.json b/vendor/bytelyst/kill-switch-client/package.json index b5426b9..5e2458c 100644 --- a/vendor/bytelyst/kill-switch-client/package.json +++ b/vendor/bytelyst/kill-switch-client/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/kill-switch-client", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "description": "Browser/React Native-safe kill switch client for platform-service", "exports": { diff --git a/vendor/bytelyst/kill-switch-client/src/index.test.ts b/vendor/bytelyst/kill-switch-client/src/index.test.ts new file mode 100644 index 0000000..7ac17a3 --- /dev/null +++ b/vendor/bytelyst/kill-switch-client/src/index.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createKillSwitchClient } from './index.js'; + +describe('createKillSwitchClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return disabled=false when app is not disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false, message: null }), + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + expect(result.message).toBeNull(); + }); + + it('should return disabled=true with message when app is disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }), + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(true); + expect(result.message).toBe('Maintenance in progress'); + }); + + it('should fail-open on network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + expect(result.message).toBeNull(); + }); + + it('should fail-open on non-OK response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + }); + + it('should send correct product-id header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ks = createKillSwitchClient(baseConfig); + await ks.check(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/flags/kill-switch'), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-product-id': 'testapp' }), + }) + ); + }); + + it('should include platform in query string', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' }); + await ks.check(); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('platform=ios'); + }); +}); diff --git a/vendor/bytelyst/kill-switch-client/src/index.ts b/vendor/bytelyst/kill-switch-client/src/index.ts new file mode 100644 index 0000000..e41339d --- /dev/null +++ b/vendor/bytelyst/kill-switch-client/src/index.ts @@ -0,0 +1,73 @@ +/** + * Browser/React Native-safe kill switch client for platform-service. + * + * Checks GET /api/flags/kill-switch to determine if the app is disabled. + * Fail-open: returns { disabled: false } on any network error. + * + * @example + * ```ts + * import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; + * + * const ks = createKillSwitchClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * }); + * + * const result = await ks.check(); + * if (result.disabled) showBlockScreen(result.message); + * ``` + */ + +export interface KillSwitchClientConfig { + /** 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 query (e.g. "mobile", "web"). Default: "mobile". */ + platform?: string; +} + +export interface KillSwitchResult { + disabled: boolean; + message: string | null; +} + +export interface KillSwitchClient { + /** Check if the app is disabled. Fail-open on any error. */ + check(): Promise; +} + +export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient { + const { baseUrl, productId, platform = 'mobile' } = config; + + async function check(): Promise { + try { + const requestId = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + const res = await globalThis.fetch( + `${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`, + { + headers: { 'x-product-id': productId, 'x-request-id': requestId }, + } + ); + + if (!res.ok) return { disabled: false, message: null }; + + const data = (await res.json()) as KillSwitchResult; + return { + disabled: data.disabled ?? false, + message: data.message ?? null, + }; + } catch { + // Fail-open: network errors should NOT block the user + return { disabled: false, message: null }; + } + } + + return { check }; +} diff --git a/vendor/bytelyst/kill-switch-client/tsconfig.json b/vendor/bytelyst/kill-switch-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/kill-switch-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/llm/package.json b/vendor/bytelyst/llm/package.json index cbe5d2d..215c0c1 100644 --- a/vendor/bytelyst/llm/package.json +++ b/vendor/bytelyst/llm/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/llm", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "exports": { ".": { diff --git a/vendor/bytelyst/llm/src/__tests__/fallback.test.ts b/vendor/bytelyst/llm/src/__tests__/fallback.test.ts new file mode 100644 index 0000000..89d4152 --- /dev/null +++ b/vendor/bytelyst/llm/src/__tests__/fallback.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for createFallbackChain. + */ + +import { describe, it, expect } from 'vitest'; +import { createFallbackChain } from '../fallback.js'; +import { MockLLMProvider } from '../providers/mock.js'; +import type { ChatCompletionResponse } from '../types.js'; + +const makeResponse = (content: string): ChatCompletionResponse => ({ + content, + model: 'mock', + finishReason: 'stop', + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, +}); + +describe('createFallbackChain', () => { + it('isConfigured returns true when at least one provider is configured', () => { + const a = new MockLLMProvider(); + const chain = createFallbackChain([a]); + expect(chain.isConfigured()).toBe(true); + }); + + it('isConfigured returns false when no providers are configured', () => { + const unconfigured = { + isConfigured: () => false, + chatCompletion: async () => { + throw new Error('not configured'); + }, + }; + const chain = createFallbackChain([unconfigured]); + expect(chain.isConfigured()).toBe(false); + }); + + it('returns response from first configured provider', async () => { + const a = new MockLLMProvider([makeResponse('from-a')]); + const b = new MockLLMProvider([makeResponse('from-b')]); + const chain = createFallbackChain([a, b]); + + const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + expect(result.content).toBe('from-a'); + expect(b.calls).toHaveLength(0); + }); + + it('falls back to second provider when first throws', async () => { + const a = { + isConfigured: () => true, + chatCompletion: async (): Promise => { + throw new Error('a failed'); + }, + }; + const b = new MockLLMProvider([makeResponse('from-b')]); + const chain = createFallbackChain([a, b]); + + const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + expect(result.content).toBe('from-b'); + }); + + it('skips unconfigured providers', async () => { + const unconfigured = { + isConfigured: () => false, + chatCompletion: async (): Promise => { + throw new Error('should not be called'); + }, + }; + const b = new MockLLMProvider([makeResponse('from-b')]); + const chain = createFallbackChain([unconfigured, b]); + + const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + expect(result.content).toBe('from-b'); + }); + + it('throws with all error messages when every provider fails', async () => { + const a = { + isConfigured: () => true, + chatCompletion: async (): Promise => { + throw new Error('a failed'); + }, + }; + const b = { + isConfigured: () => true, + chatCompletion: async (): Promise => { + throw new Error('b failed'); + }, + }; + const chain = createFallbackChain([a, b]); + + await expect( + chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) + ).rejects.toThrow('All providers failed: a failed | b failed'); + }); + + it('throws "No providers configured" when list is empty', async () => { + const chain = createFallbackChain([]); + await expect( + chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) + ).rejects.toThrow('No providers configured'); + }); +}); diff --git a/vendor/bytelyst/llm/src/__tests__/llm.test.ts b/vendor/bytelyst/llm/src/__tests__/llm.test.ts new file mode 100644 index 0000000..8db6b8d --- /dev/null +++ b/vendor/bytelyst/llm/src/__tests__/llm.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for LLM providers, factory, types, and helpers. + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { MockLLMProvider } from '../providers/mock.js'; +import { createLLMProvider, _resetLLM } from '../factory.js'; +import { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from '../types.js'; +import type { ChatMessage, ChatCompletionRequest, EmbeddingResponse } from '../types.js'; + +// ── Helper function tests ───────────────────────────────────────── + +describe('isVisionMessage', () => { + it('returns false for string content', () => { + const msg: ChatMessage = { role: 'user', content: 'hello' }; + expect(isVisionMessage(msg)).toBe(false); + }); + + it('returns false for text-only multipart', () => { + const msg: ChatMessage = { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }; + expect(isVisionMessage(msg)).toBe(false); + }); + + it('returns true when message contains image_url part', () => { + const msg: ChatMessage = { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ], + }; + expect(isVisionMessage(msg)).toBe(true); + }); +}); + +describe('hasVisionContent', () => { + it('returns false for text-only request', () => { + const req: ChatCompletionRequest = { + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'hello' }, + ], + }; + expect(hasVisionContent(req)).toBe(false); + }); + + it('returns true when any message has image content', () => { + const req: ChatCompletionRequest = { + messages: [ + { role: 'system', content: 'You are helpful' }, + { + role: 'user', + content: [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }, + ], + }, + ], + }; + expect(hasVisionContent(req)).toBe(true); + }); +}); + +describe('buildVisionMessage', () => { + it('builds a multipart user message with text and image', () => { + const msg = buildVisionMessage('Describe this', 'https://img.com/a.png'); + expect(msg.role).toBe('user'); + expect(Array.isArray(msg.content)).toBe(true); + const parts = msg.content as Array<{ type: string }>; + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ type: 'text', text: 'Describe this' }); + expect(parts[1]).toEqual({ + type: 'image_url', + image_url: { url: 'https://img.com/a.png', detail: 'auto' }, + }); + }); + + it('respects detail parameter', () => { + const msg = buildVisionMessage('hi', 'https://img.com/b.png', 'high'); + const parts = msg.content as Array<{ type: string; image_url?: { detail: string } }>; + expect(parts[1]?.image_url?.detail).toBe('high'); + }); +}); + +describe('getMessageText', () => { + it('returns string content directly', () => { + expect(getMessageText({ role: 'user', content: 'hello' })).toBe('hello'); + }); + + it('extracts text from multipart content', () => { + const msg: ChatMessage = { + role: 'user', + content: [ + { type: 'text', text: 'line one' }, + { type: 'image_url', image_url: { url: 'https://img.com/x.png' } }, + { type: 'text', text: 'line two' }, + ], + }; + expect(getMessageText(msg)).toBe('line one\nline two'); + }); + + it('returns empty for image-only multipart', () => { + const msg: ChatMessage = { + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'https://img.com/x.png' } }], + }; + expect(getMessageText(msg)).toBe(''); + }); +}); + +// ── MockLLMProvider tests ───────────────────────────────────────── + +describe('MockLLMProvider', () => { + let provider: MockLLMProvider; + + beforeEach(() => { + provider = new MockLLMProvider(); + }); + + it('isConfigured returns true', () => { + expect(provider.isConfigured()).toBe(true); + }); + + it('returns default echo response', async () => { + const result = await provider.chatCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + }); + expect(result.content).toContain('Hello'); + expect(result.finishReason).toBe('stop'); + }); + + it('returns queued responses', async () => { + provider.addResponse({ + content: 'Custom response', + model: 'test-model', + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 }, + }); + + const result = await provider.chatCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + }); + expect(result.content).toBe('Custom response'); + }); + + it('tracks calls', async () => { + const req = { messages: [{ role: 'user' as const, content: 'Test' }] }; + await provider.chatCompletion(req); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0]).toEqual(req); + }); + + it('reset clears calls and responses', async () => { + provider.addResponse({ + content: 'x', + model: 'm', + finishReason: 'stop', + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }); + await provider.chatCompletion({ messages: [{ role: 'user', content: 'Test' }] }); + provider.reset(); + expect(provider.calls).toHaveLength(0); + }); + + it('handles multipart (vision) content in echo response', async () => { + const result = await provider.chatCompletion({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image' }, + { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, + ], + }, + ], + }); + expect(result.content).toContain('Describe this image'); + }); + + // ── Streaming tests ────────────────────────────────── + + it('streams default echo response word by word', async () => { + const stream = provider.chatCompletionStream!({ + messages: [{ role: 'user', content: 'Hi' }], + }); + const chunks: string[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + const full = chunks.join(''); + expect(full).toContain('Hi'); + }); + + it('streams queued response word by word', async () => { + provider.addResponse({ + content: 'Hello World', + model: 'test', + finishReason: 'stop', + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + }); + const stream = provider.chatCompletionStream!({ + messages: [{ role: 'user', content: 'x' }], + }); + const chunks: string[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks).toEqual(['Hello ', 'World ']); + }); + + it('streaming tracks calls', async () => { + const req = { messages: [{ role: 'user' as const, content: 'stream test' }] }; + const stream = provider.chatCompletionStream!(req); + const drained: string[] = []; + for await (const chunk of stream) { + drained.push(chunk); + } + expect(drained.length).toBeGreaterThan(0); + expect(provider.calls).toHaveLength(1); + }); + + // ── Embedding tests ────────────────────────────────── + + it('returns deterministic embeddings for single input', async () => { + const result = await provider.embed!({ input: 'hello world' }); + expect(result.embeddings).toHaveLength(1); + expect(result.embeddings[0].length).toBe(8); + expect(result.model).toBe('mock-embedding-model'); + // Verify normalized (magnitude ≈ 1) + const mag = Math.sqrt(result.embeddings[0].reduce((s, v) => s + v * v, 0)); + expect(mag).toBeCloseTo(1.0, 3); + }); + + it('returns multiple embeddings for array input', async () => { + const result = await provider.embed!({ input: ['hello', 'world'] }); + expect(result.embeddings).toHaveLength(2); + expect(result.embeddings[0]).not.toEqual(result.embeddings[1]); + }); + + it('returns queued embedding response', async () => { + const custom: EmbeddingResponse = { + embeddings: [[0.1, 0.2, 0.3]], + model: 'custom-embed', + usage: { promptTokens: 1, completionTokens: 0, totalTokens: 1 }, + }; + provider.addEmbeddingResponse(custom); + const result = await provider.embed!({ input: 'test' }); + expect(result).toEqual(custom); + }); + + it('tracks embed calls', async () => { + await provider.embed!({ input: 'track me' }); + expect(provider.embedCalls).toHaveLength(1); + expect(provider.embedCalls[0].input).toBe('track me'); + }); + + it('deterministic: same input produces same embedding', async () => { + const r1 = await provider.embed!({ input: 'identical text' }); + const r2 = await provider.embed!({ input: 'identical text' }); + expect(r1.embeddings[0]).toEqual(r2.embeddings[0]); + }); + + it('reset clears embed state', async () => { + const custom: EmbeddingResponse = { + embeddings: [[0.5]], + model: 'm', + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }; + provider.addEmbeddingResponse(custom); + await provider.embed!({ input: 'test' }); + provider.reset(); + expect(provider.embedCalls).toHaveLength(0); + // After reset, embed should return default (not queued) response + const result = await provider.embed!({ input: 'after reset' }); + expect(result.model).toBe('mock-embedding-model'); + }); +}); + +// ── Factory tests ───────────────────────────────────────────────── + +describe('createLLMProvider', () => { + afterEach(() => { + _resetLLM(); + vi.unstubAllEnvs(); + }); + + it('creates mock provider', () => { + const provider = createLLMProvider('mock'); + expect(provider).toBeInstanceOf(MockLLMProvider); + }); + + it('throws for unknown type', () => { + expect(() => createLLMProvider('unknown' as 'mock')).toThrow('Unknown LLM_PROVIDER'); + }); +}); diff --git a/vendor/bytelyst/llm/src/__tests__/providers.test.ts b/vendor/bytelyst/llm/src/__tests__/providers.test.ts new file mode 100644 index 0000000..7ba1510 --- /dev/null +++ b/vendor/bytelyst/llm/src/__tests__/providers.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for PerplexityProvider and GeminiProvider. + * Uses vi.stubGlobal to mock fetch — no real API calls. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { PerplexityProvider } from '../providers/perplexity.js'; +import { GeminiProvider } from '../providers/gemini.js'; + +const makeOpenAIResponse = (content: string, model = 'test-model') => ({ + choices: [{ message: { content }, finish_reason: 'stop' }], + model, + usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, +}); + +const makeGeminiResponse = (text: string) => ({ + candidates: [{ content: { parts: [{ text }] }, finishReason: 'STOP' }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10, totalTokenCount: 15 }, +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +// ── PerplexityProvider ────────────────────────────────────────── + +describe('PerplexityProvider', () => { + it('isConfigured false without API key', () => { + const p = new PerplexityProvider({ apiKey: '' }); + expect(p.isConfigured()).toBe(false); + }); + + it('isConfigured true with API key', () => { + const p = new PerplexityProvider({ apiKey: 'test-key' }); + expect(p.isConfigured()).toBe(true); + }); + + it('reads apiKey from env', () => { + vi.stubEnv('PERPLEXITY_API_KEY', 'env-key'); + const p = new PerplexityProvider(); + expect(p.isConfigured()).toBe(true); + }); + + it('throws when not configured', async () => { + const p = new PerplexityProvider({ apiKey: '' }); + await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( + 'Perplexity is not configured' + ); + }); + + it('calls Perplexity API and maps response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse('analysis result', 'sonar'), + }); + vi.stubGlobal('fetch', fetchMock); + + const p = new PerplexityProvider({ apiKey: 'test-key', model: 'sonar' }); + const result = await p.chatCompletion({ + messages: [{ role: 'user', content: 'analyse BTC' }], + temperature: 0.2, + }); + + expect(result.content).toBe('analysis result'); + expect(result.model).toBe('sonar'); + expect(result.finishReason).toBe('stop'); + expect(result.usage.totalTokens).toBe(15); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.perplexity.ai/chat/completions'); + expect((init.headers as Record)['Authorization']).toBe('Bearer test-key'); + }); + + it('throws on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => 'rate limited', + }) + ); + + const p = new PerplexityProvider({ apiKey: 'test-key' }); + await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( + 'Perplexity error 429' + ); + }); +}); + +// ── GeminiProvider ────────────────────────────────────────────── + +describe('GeminiProvider', () => { + it('isConfigured false without API key', () => { + const p = new GeminiProvider({ apiKey: '' }); + expect(p.isConfigured()).toBe(false); + }); + + it('isConfigured true with API key', () => { + const p = new GeminiProvider({ apiKey: 'test-key' }); + expect(p.isConfigured()).toBe(true); + }); + + it('reads apiKey from env', () => { + vi.stubEnv('GEMINI_API_KEY', 'env-key'); + const p = new GeminiProvider(); + expect(p.isConfigured()).toBe(true); + }); + + it('throws when not configured', async () => { + const p = new GeminiProvider({ apiKey: '' }); + await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( + 'Gemini is not configured' + ); + }); + + it('calls Gemini API and maps response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeGeminiResponse('gemini analysis'), + }); + vi.stubGlobal('fetch', fetchMock); + + const p = new GeminiProvider({ apiKey: 'test-key', model: 'gemini-1.5-flash' }); + const result = await p.chatCompletion({ + messages: [ + { role: 'system', content: 'You are a trading assistant.' }, + { role: 'user', content: 'analyse BTC' }, + ], + temperature: 0.2, + }); + + expect(result.content).toBe('gemini analysis'); + expect(result.model).toBe('gemini-1.5-flash'); + expect(result.finishReason).toBe('stop'); + expect(result.usage.totalTokens).toBe(15); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('generativelanguage.googleapis.com'); + expect(url).toContain('gemini-1.5-flash'); + expect(url).toContain('test-key'); + + const body = JSON.parse(init.body as string); + expect(body.systemInstruction.parts[0].text).toBe('You are a trading assistant.'); + expect(body.contents[0].role).toBe('user'); + }); + + it('maps MAX_TOKENS finish reason to length', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: 'truncated' }] }, finishReason: 'MAX_TOKENS' }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, + }), + }) + ); + + const p = new GeminiProvider({ apiKey: 'test-key' }); + const result = await p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + expect(result.finishReason).toBe('length'); + }); + + it('throws on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => 'bad request', + }) + ); + + const p = new GeminiProvider({ apiKey: 'test-key' }); + await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( + 'Gemini error 400' + ); + }); +}); diff --git a/vendor/bytelyst/llm/src/factory.ts b/vendor/bytelyst/llm/src/factory.ts new file mode 100644 index 0000000..3765167 --- /dev/null +++ b/vendor/bytelyst/llm/src/factory.ts @@ -0,0 +1,105 @@ +/** + * LLM provider factory. + * + * Creates an LLMProvider based on LLM_PROVIDER env var. + * Auto-detects provider from endpoint/key env vars if not explicitly set. + * + * Provider selection priority: + * LLM_PROVIDER env var > auto-detect from endpoint/key env vars > openai + * + * To use a fallback chain (e.g. perplexity → openai → gemini), set: + * LLM_PROVIDER=fallback + * LLM_FALLBACK_ORDER=perplexity,openai,gemini (default if unset) + */ + +import { AzureOpenAIProvider } from './providers/azure-openai.js'; +import { FallbackLLMProvider } from './providers/fallback.js'; +import { GeminiProvider } from './providers/gemini.js'; +import { MockLLMProvider } from './providers/mock.js'; +import { OpenAIProvider } from './providers/openai.js'; +import { PerplexityProvider } from './providers/perplexity.js'; +import type { LLMProvider, LLMProviderType } from './types.js'; + +let _provider: LLMProvider | null = null; + +/** + * Resolve provider type from env vars. + * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from keys/endpoints. + */ +function resolveProviderType(): LLMProviderType { + const explicit = (process.env.LLM_PROVIDER || process.env.OPENAI_PROVIDER || '').toLowerCase(); + if (explicit === 'azure') return 'azure'; + if (explicit === 'openai') return 'openai'; + if (explicit === 'perplexity') return 'perplexity'; + if (explicit === 'gemini') return 'gemini'; + if (explicit === 'fallback') return 'fallback'; + if (explicit === 'mock') return 'mock'; + + // Auto-detect from environment + const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT ?? ''; + const baseUrl = process.env.OPENAI_BASE_URL ?? ''; + if (azureEndpoint.trim().length > 0) return 'azure'; + if (baseUrl.includes('.cognitive.microsoft.com') || baseUrl.includes('.openai.azure.com')) + return 'azure'; + if (process.env.PERPLEXITY_API_KEY) return 'perplexity'; + if (process.env.GEMINI_API_KEY) return 'gemini'; + + return 'openai'; +} + +/** + * Get the singleton LLM provider. + */ +export function getLLM(): LLMProvider { + if (!_provider) { + _provider = createLLMProvider(resolveProviderType()); + } + return _provider; +} + +/** + * Create an LLM provider by type. + * For 'fallback', reads LLM_FALLBACK_ORDER env var (comma-separated provider names). + */ +export function createLLMProvider(type: LLMProviderType): LLMProvider { + switch (type) { + case 'azure': + return new AzureOpenAIProvider(); + case 'openai': + return new OpenAIProvider(); + case 'perplexity': + return new PerplexityProvider(); + case 'gemini': + return new GeminiProvider(); + case 'fallback': { + const order = (process.env.LLM_FALLBACK_ORDER ?? 'perplexity,openai,gemini') + .split(',') + .map(s => s.trim() as LLMProviderType) + .filter(name => name && name !== 'fallback'); // prevent infinite recursion + if (order.length === 0) { + throw new Error('LLM_FALLBACK_ORDER must contain at least one non-fallback provider'); + } + return new FallbackLLMProvider(order.map(createLLMProvider)); + } + case 'mock': + return new MockLLMProvider(); + default: + throw new Error( + `Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, perplexity, gemini, fallback, mock` + ); + } +} + +/** + * Set the singleton LLM provider (for testing). + */ +export function setLLM(provider: LLMProvider): void { + _provider = provider; +} + +/** + * @internal + */ +export function _resetLLM(): void { + _provider = null; +} diff --git a/vendor/bytelyst/llm/src/fallback.ts b/vendor/bytelyst/llm/src/fallback.ts new file mode 100644 index 0000000..1f4fcb3 --- /dev/null +++ b/vendor/bytelyst/llm/src/fallback.ts @@ -0,0 +1,36 @@ +/** + * Fallback chain utility. + * + * Wraps an ordered list of LLMProviders into a single LLMProvider that + * tries each in sequence, skipping unconfigured ones, and moves to the + * next on any error. Throws only when all providers are exhausted. + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from './types.js'; + +export function createFallbackChain(providers: LLMProvider[]): LLMProvider { + return { + isConfigured(): boolean { + return providers.some(p => p.isConfigured()); + }, + + async chatCompletion(req: ChatCompletionRequest): Promise { + const errors: string[] = []; + + for (const provider of providers) { + if (!provider.isConfigured()) continue; + try { + return await provider.chatCompletion(req); + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)); + } + } + + throw new Error( + errors.length > 0 + ? `All providers failed: ${errors.join(' | ')}` + : 'No providers configured' + ); + }, + }; +} diff --git a/vendor/bytelyst/llm/src/index.ts b/vendor/bytelyst/llm/src/index.ts new file mode 100644 index 0000000..e3c2993 --- /dev/null +++ b/vendor/bytelyst/llm/src/index.ts @@ -0,0 +1,24 @@ +export type { + LLMProvider, + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + TokenUsage, + LLMProviderType, + ContentPart, + TextContentPart, + ImageUrlContentPart, + EmbeddingRequest, + EmbeddingResponse, +} from './types.js'; + +export { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from './types.js'; + +export { getLLM, createLLMProvider, setLLM, _resetLLM } from './factory.js'; +export { createFallbackChain } from './fallback.js'; +export { AzureOpenAIProvider, type AzureOpenAIConfig } from './providers/azure-openai.js'; +export { GeminiProvider, type GeminiConfig } from './providers/gemini.js'; +export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js'; +export { PerplexityProvider, type PerplexityConfig } from './providers/perplexity.js'; +export { FallbackLLMProvider } from './providers/fallback.js'; +export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/providers/azure-openai.ts b/vendor/bytelyst/llm/src/providers/azure-openai.ts new file mode 100644 index 0000000..badf172 --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/azure-openai.ts @@ -0,0 +1,226 @@ +/** + * Azure OpenAI LLM provider. + * + * Uses Azure OpenAI REST API with api-key authentication. + * Reads config from AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT. + * Supports text, vision (multipart content), streaming, and embeddings. + */ + +import type { + ChatCompletionRequest, + ChatCompletionResponse, + EmbeddingRequest, + EmbeddingResponse, + LLMProvider, +} from '../types.js'; + +export interface AzureOpenAIConfig { + endpoint: string; + apiKey: string; + deployment: string; + embeddingDeployment?: string; + apiVersion?: string; +} + +export class AzureOpenAIProvider implements LLMProvider { + private config: AzureOpenAIConfig; + + constructor(config?: Partial) { + this.config = { + endpoint: config?.endpoint || process.env.AZURE_OPENAI_ENDPOINT || '', + apiKey: config?.apiKey || process.env.AZURE_OPENAI_KEY || process.env.OPENAI_API_KEY || '', + deployment: + config?.deployment || + process.env.AZURE_OPENAI_DEPLOYMENT || + process.env.OPENAI_MODEL || + 'gpt-4o-mini', + embeddingDeployment: + config?.embeddingDeployment || + process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT || + 'text-embedding-3-small', + apiVersion: config?.apiVersion || process.env.AZURE_OPENAI_API_VERSION || '2024-06-01', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.endpoint && this.config.apiKey && this.config.deployment); + } + + private getBaseUrl(): string { + return this.config.endpoint.replace(/\/+$/, ''); + } + + private getChatUrl(): string { + const base = this.getBaseUrl(); + const deployment = encodeURIComponent(this.config.deployment); + const version = encodeURIComponent(this.config.apiVersion!); + return `${base}/openai/deployments/${deployment}/chat/completions?api-version=${version}`; + } + + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + 'api-key': this.config.apiKey, + }; + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error( + 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' + ); + } + + const url = this.getChatUrl(); + + const body = { + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Azure OpenAI error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string }; finish_reason: string }>; + model: string; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + + return { + content: data.choices[0]?.message?.content ?? '', + model: data.model, + finishReason: + (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + }, + }; + } + + async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { + if (!this.isConfigured()) { + throw new Error( + 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' + ); + } + + const url = this.getChatUrl(); + + const body = { + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + stream: true, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Azure OpenAI streaming error ${response.status}: ${text}`); + } + + if (!response.body) { + throw new Error('Azure OpenAI streaming: no response body'); + } + + 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 || !trimmed.startsWith('data: ')) continue; + const data = trimmed.slice(6); + if (data === '[DONE]') return; + try { + const parsed = JSON.parse(data) as { + choices: Array<{ delta: { content?: string } }>; + }; + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) yield delta; + } catch { + // skip malformed SSE chunks + } + } + } + } finally { + reader.releaseLock(); + } + } + + async embed(req: EmbeddingRequest): Promise { + if (!this.isConfigured()) { + throw new Error( + 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' + ); + } + + const base = this.getBaseUrl(); + const deployment = encodeURIComponent(this.config.embeddingDeployment!); + const version = encodeURIComponent(this.config.apiVersion!); + const url = `${base}/openai/deployments/${deployment}/embeddings?api-version=${version}`; + + const body = { + input: req.input, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Azure OpenAI embedding error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[]; index: number }>; + model: string; + usage: { prompt_tokens: number; total_tokens: number }; + }; + + return { + embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), + model: data.model, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: 0, + totalTokens: data.usage.total_tokens, + }, + }; + } +} diff --git a/vendor/bytelyst/llm/src/providers/fallback.ts b/vendor/bytelyst/llm/src/providers/fallback.ts new file mode 100644 index 0000000..24e199c --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/fallback.ts @@ -0,0 +1,47 @@ +/** + * Fallback LLM provider. + * + * Tries each provider in order, falling back to the next on error or + * when a provider is not configured. Useful for resilient AI pipelines + * (e.g. perplexity → openai → gemini). + * + * Usage: + * const llm = new FallbackLLMProvider([ + * new PerplexityProvider(), + * new OpenAIProvider(), + * new GeminiProvider(), + * ]); + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export class FallbackLLMProvider implements LLMProvider { + constructor(private readonly providers: LLMProvider[]) { + if (providers.length === 0) { + throw new Error('FallbackLLMProvider requires at least one provider'); + } + } + + isConfigured(): boolean { + return this.providers.some(p => p.isConfigured()); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + const errors: string[] = []; + + for (const provider of this.providers) { + if (!provider.isConfigured()) { + errors.push(`${provider.constructor.name}: not configured`); + continue; + } + try { + return await provider.chatCompletion(req); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${provider.constructor.name}: ${msg}`); + } + } + + throw new Error(`All LLM providers failed:\n${errors.map(e => ` - ${e}`).join('\n')}`); + } +} diff --git a/vendor/bytelyst/llm/src/providers/gemini.ts b/vendor/bytelyst/llm/src/providers/gemini.ts new file mode 100644 index 0000000..194fbb7 --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/gemini.ts @@ -0,0 +1,122 @@ +/** + * Google Gemini LLM provider. + * + * Uses Google's Generative Language API (not OpenAI-compatible). + * Reads config from GEMINI_API_KEY, GEMINI_MODEL. + */ + +import type { + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + LLMProvider, +} from '../types.js'; +import { getMessageText } from '../types.js'; + +export interface GeminiConfig { + apiKey: string; + model?: string; +} + +interface GeminiPart { + text: string; +} + +interface GeminiContent { + role: 'user' | 'model'; + parts: GeminiPart[]; +} + +export class GeminiProvider implements LLMProvider { + private config: GeminiConfig; + + constructor(config?: Partial) { + this.config = { + apiKey: config?.apiKey || process.env.GEMINI_API_KEY || '', + model: config?.model || process.env.GEMINI_MODEL || 'gemini-1.5-flash', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.apiKey); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error('Gemini is not configured (missing GEMINI_API_KEY)'); + } + + const model = req.model || this.config.model!; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${this.config.apiKey}`; + + const { systemInstruction, contents } = this.convertMessages(req.messages); + + const body: Record = { contents }; + if (systemInstruction) { + body.systemInstruction = { parts: [{ text: systemInstruction }] }; + } + if (req.temperature !== undefined) { + body.generationConfig = { temperature: req.temperature }; + } + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Gemini error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + candidates: Array<{ + content: { parts: GeminiPart[] }; + finishReason: string; + }>; + usageMetadata?: { + promptTokenCount: number; + candidatesTokenCount: number; + totalTokenCount: number; + }; + }; + + const content = data.candidates[0]?.content?.parts?.map(p => p.text).join('') ?? ''; + const finishReason = data.candidates[0]?.finishReason; + + return { + content, + model, + finishReason: + finishReason === 'STOP' ? 'stop' : finishReason === 'MAX_TOKENS' ? 'length' : null, + usage: { + promptTokens: data.usageMetadata?.promptTokenCount ?? 0, + completionTokens: data.usageMetadata?.candidatesTokenCount ?? 0, + totalTokens: data.usageMetadata?.totalTokenCount ?? 0, + }, + }; + } + + private convertMessages(messages: ChatMessage[]): { + systemInstruction: string | null; + contents: GeminiContent[]; + } { + const systemMessages = messages.filter(m => m.role === 'system'); + const systemInstruction = systemMessages.map(m => getMessageText(m)).join('\n') || null; + + const contents: GeminiContent[] = messages + .filter(m => m.role !== 'system') + .map(m => ({ + role: (m.role === 'assistant' ? 'model' : 'user') as 'user' | 'model', + parts: [{ text: getMessageText(m) }], + })); + + // Gemini requires at least one user turn + if (contents.length === 0) { + contents.push({ role: 'user', parts: [{ text: '' }] }); + } + + return { systemInstruction, contents }; + } +} diff --git a/vendor/bytelyst/llm/src/providers/mock.ts b/vendor/bytelyst/llm/src/providers/mock.ts new file mode 100644 index 0000000..ad624cf --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/mock.ts @@ -0,0 +1,118 @@ +/** + * Mock LLM provider — for testing. + * + * Returns pre-configured responses or a default echo response. + * Supports vision content, streaming, and embedding. + */ + +import type { + ChatCompletionRequest, + ChatCompletionResponse, + EmbeddingRequest, + EmbeddingResponse, + LLMProvider, +} from '../types.js'; +import { getMessageText } from '../types.js'; + +export class MockLLMProvider implements LLMProvider { + private responses: ChatCompletionResponse[] = []; + private embeddingResponses: EmbeddingResponse[] = []; + public calls: ChatCompletionRequest[] = []; + public embedCalls: EmbeddingRequest[] = []; + + constructor(responses?: ChatCompletionResponse[]) { + if (responses) this.responses = [...responses]; + } + + isConfigured(): boolean { + return true; + } + + /** Add a chat response to the queue. */ + addResponse(response: ChatCompletionResponse): void { + this.responses.push(response); + } + + /** Add an embedding response to the queue. */ + addEmbeddingResponse(response: EmbeddingResponse): void { + this.embeddingResponses.push(response); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + this.calls.push(req); + + if (this.responses.length > 0) { + return this.responses.shift()!; + } + + // Default echo response — handles both string and multipart content + const lastMessage = req.messages[req.messages.length - 1]; + const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; + return { + content: `Mock response to: ${text}`, + model: req.model ?? 'mock-model', + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 }, + }; + } + + async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { + this.calls.push(req); + + if (this.responses.length > 0) { + const resp = this.responses.shift()!; + // Yield word-by-word to simulate streaming + const words = resp.content.split(' '); + for (const word of words) { + yield word + ' '; + } + return; + } + + const lastMessage = req.messages[req.messages.length - 1]; + const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; + const words = `Mock response to: ${text}`.split(' '); + for (const word of words) { + yield word + ' '; + } + } + + async embed(req: EmbeddingRequest): Promise { + this.embedCalls.push(req); + + if (this.embeddingResponses.length > 0) { + return this.embeddingResponses.shift()!; + } + + // Default: return deterministic pseudo-embeddings (dimension 8 for testing) + const inputs = Array.isArray(req.input) ? req.input : [req.input]; + const embeddings = inputs.map(text => { + // Simple hash-based deterministic vector for testing + const vec = new Array(8).fill(0); + for (let i = 0; i < text.length; i++) { + vec[i % 8] += text.charCodeAt(i) / 1000; + } + // Normalize + const mag = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1; + return vec.map(v => v / mag); + }); + + return { + embeddings, + model: req.model ?? 'mock-embedding-model', + usage: { + promptTokens: inputs.join(' ').split(/\s+/).length, + completionTokens: 0, + totalTokens: inputs.join(' ').split(/\s+/).length, + }, + }; + } + + /** Reset call history and responses. */ + reset(): void { + this.calls = []; + this.embedCalls = []; + this.responses = []; + this.embeddingResponses = []; + } +} diff --git a/vendor/bytelyst/llm/src/providers/openai.ts b/vendor/bytelyst/llm/src/providers/openai.ts new file mode 100644 index 0000000..a355c14 --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/openai.ts @@ -0,0 +1,205 @@ +/** + * OpenAI direct LLM provider. + * + * Uses OpenAI REST API with Bearer token authentication. + * Reads config from OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL. + * Supports text, vision (multipart content), streaming, and embeddings. + */ + +import type { + ChatCompletionRequest, + ChatCompletionResponse, + EmbeddingRequest, + EmbeddingResponse, + LLMProvider, +} from '../types.js'; + +export interface OpenAIConfig { + apiKey: string; + baseUrl?: string; + model?: string; + embeddingModel?: string; +} + +export class OpenAIProvider implements LLMProvider { + private config: OpenAIConfig; + + constructor(config?: Partial) { + this.config = { + apiKey: config?.apiKey || process.env.OPENAI_API_KEY || '', + baseUrl: config?.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', + model: config?.model || process.env.OPENAI_MODEL || 'gpt-4o-mini', + embeddingModel: + config?.embeddingModel || process.env.LLM_EMBEDDING_MODEL || 'text-embedding-3-small', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.apiKey); + } + + private getBaseUrl(): string { + return this.config.baseUrl!.replace(/\/+$/, ''); + } + + private getHeaders(): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }; + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); + } + + const url = `${this.getBaseUrl()}/chat/completions`; + + const body = { + model: req.model || this.config.model, + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string }; finish_reason: string }>; + model: string; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + + return { + content: data.choices[0]?.message?.content ?? '', + model: data.model, + finishReason: + (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + }, + }; + } + + async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { + if (!this.isConfigured()) { + throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); + } + + const url = `${this.getBaseUrl()}/chat/completions`; + + const body = { + model: req.model || this.config.model, + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + stop: req.stop, + response_format: req.responseFormat, + stream: true, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI streaming error ${response.status}: ${text}`); + } + + if (!response.body) { + throw new Error('OpenAI streaming: no response body'); + } + + 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 || !trimmed.startsWith('data: ')) continue; + const data = trimmed.slice(6); + if (data === '[DONE]') return; + try { + const parsed = JSON.parse(data) as { + choices: Array<{ delta: { content?: string } }>; + }; + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) yield delta; + } catch { + // skip malformed SSE chunks + } + } + } + } finally { + reader.releaseLock(); + } + } + + async embed(req: EmbeddingRequest): Promise { + if (!this.isConfigured()) { + throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); + } + + const url = `${this.getBaseUrl()}/embeddings`; + + const body = { + model: req.model || this.config.embeddingModel, + input: req.input, + }; + + const response = await fetch(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI embedding error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[]; index: number }>; + model: string; + usage: { prompt_tokens: number; total_tokens: number }; + }; + + return { + embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), + model: data.model, + usage: { + promptTokens: data.usage.prompt_tokens, + completionTokens: 0, + totalTokens: data.usage.total_tokens, + }, + }; + } +} diff --git a/vendor/bytelyst/llm/src/providers/perplexity.ts b/vendor/bytelyst/llm/src/providers/perplexity.ts new file mode 100644 index 0000000..b778311 --- /dev/null +++ b/vendor/bytelyst/llm/src/providers/perplexity.ts @@ -0,0 +1,74 @@ +/** + * Perplexity LLM provider. + * + * Uses Perplexity's OpenAI-compatible API with real-time web search. + * Reads config from PERPLEXITY_API_KEY, PERPLEXITY_MODEL. + */ + +import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; + +export interface PerplexityConfig { + apiKey: string; + model?: string; +} + +export class PerplexityProvider implements LLMProvider { + private config: PerplexityConfig; + + constructor(config?: Partial) { + this.config = { + apiKey: config?.apiKey || process.env.PERPLEXITY_API_KEY || '', + model: config?.model || process.env.PERPLEXITY_MODEL || 'sonar', + }; + } + + isConfigured(): boolean { + return Boolean(this.config.apiKey); + } + + async chatCompletion(req: ChatCompletionRequest): Promise { + if (!this.isConfigured()) { + throw new Error('Perplexity is not configured (missing PERPLEXITY_API_KEY)'); + } + + const body = { + model: req.model || this.config.model, + messages: req.messages, + temperature: req.temperature, + max_tokens: req.maxTokens, + top_p: req.topP, + }; + + const response = await fetch('https://api.perplexity.ai/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Perplexity error ${response.status}: ${text}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string }; finish_reason: string }>; + model: string; + usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + }; + + return { + content: data.choices[0]?.message?.content ?? '', + model: data.model, + finishReason: + (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, + usage: { + promptTokens: data.usage?.prompt_tokens ?? 0, + completionTokens: data.usage?.completion_tokens ?? 0, + totalTokens: data.usage?.total_tokens ?? 0, + }, + }; + } +} diff --git a/vendor/bytelyst/llm/src/testing.ts b/vendor/bytelyst/llm/src/testing.ts new file mode 100644 index 0000000..18d2c6c --- /dev/null +++ b/vendor/bytelyst/llm/src/testing.ts @@ -0,0 +1,18 @@ +/** + * Test helpers for @bytelyst/llm. + */ + +import { setLLM, _resetLLM } from './factory.js'; +import { MockLLMProvider } from './providers/mock.js'; + +export function setTestLLMProvider(): MockLLMProvider { + const provider = new MockLLMProvider(); + setLLM(provider); + return provider; +} + +export function resetTestLLM(): void { + _resetLLM(); +} + +export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/types.ts b/vendor/bytelyst/llm/src/types.ts new file mode 100644 index 0000000..c5eb284 --- /dev/null +++ b/vendor/bytelyst/llm/src/types.ts @@ -0,0 +1,131 @@ +/** + * Cloud-agnostic LLM provider interfaces. + * + * Provides a unified chat completion API that works with + * Azure OpenAI, OpenAI direct, Perplexity, Gemini, or mock providers. + * Supports text, vision (image), and embedding modalities. + */ + +// ── Content Parts (vision support) ──────────────────────────────── + +/** A text segment within a multipart message. */ +export interface TextContentPart { + type: 'text'; + text: string; +} + +/** An image URL segment within a multipart message (vision). */ +export interface ImageUrlContentPart { + type: 'image_url'; + image_url: { url: string; detail?: 'auto' | 'low' | 'high' }; +} + +/** A single part of a multipart message — text or image. */ +export type ContentPart = TextContentPart | ImageUrlContentPart; + +// ── Chat Messages ───────────────────────────────────────────────── + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + /** Text string OR multipart content array (for vision messages). */ + content: string | ContentPart[]; + name?: string; +} + +// ── Provider Interface ──────────────────────────────────────────── + +export interface LLMProvider { + /** Send a chat completion request. */ + chatCompletion(req: ChatCompletionRequest): Promise; + + /** Stream a chat completion response — yields content delta strings. */ + chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable; + + /** Generate vector embeddings for input text(s). */ + embed?(req: EmbeddingRequest): Promise; + + /** Check if the provider is configured with valid credentials. */ + isConfigured(): boolean; +} + +// ── Chat Completion ─────────────────────────────────────────────── + +export interface ChatCompletionRequest { + messages: ChatMessage[]; + model?: string; + temperature?: number; + maxTokens?: number; + topP?: number; + stop?: string[]; + responseFormat?: { type: 'text' | 'json_object' }; +} + +export interface ChatCompletionResponse { + content: string; + model: string; + usage: TokenUsage; + finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null; +} + +// ── Embeddings ──────────────────────────────────────────────────── + +export interface EmbeddingRequest { + /** One or more texts to embed. */ + input: string | string[]; + /** Override the default embedding model. */ + model?: string; +} + +export interface EmbeddingResponse { + /** One embedding vector per input string. */ + embeddings: number[][]; + model: string; + usage: TokenUsage; +} + +// ── Shared ──────────────────────────────────────────────────────── + +export interface TokenUsage { + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export type LLMProviderType = 'azure' | 'openai' | 'perplexity' | 'gemini' | 'fallback' | 'mock'; + +// ── Helpers ─────────────────────────────────────────────────────── + +/** Type guard: does this message contain image content parts? */ +export function isVisionMessage(msg: ChatMessage): boolean { + if (typeof msg.content === 'string') return false; + return msg.content.some(p => p.type === 'image_url'); +} + +/** Does the request contain any vision (image) messages? */ +export function hasVisionContent(req: ChatCompletionRequest): boolean { + return req.messages.some(isVisionMessage); +} + +/** Convenience builder for a user message with text + image. */ +export function buildVisionMessage( + text: string, + imageUrl: string, + detail: 'auto' | 'low' | 'high' = 'auto' +): ChatMessage { + return { + role: 'user', + content: [ + { type: 'text', text }, + { type: 'image_url', image_url: { url: imageUrl, detail } }, + ], + }; +} + +/** Extract plain text from a ChatMessage content (string or multipart). */ +export function getMessageText(msg: ChatMessage): string { + if (typeof msg.content === 'string') return msg.content; + return msg.content + .filter((p): p is TextContentPart => p.type === 'text') + .map(p => p.text) + .join('\n'); +} diff --git a/vendor/bytelyst/llm/tsconfig.json b/vendor/bytelyst/llm/tsconfig.json new file mode 100644 index 0000000..5edad81 --- /dev/null +++ b/vendor/bytelyst/llm/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/react-auth/package.json b/vendor/bytelyst/react-auth/package.json index b73d202..74b1270 100644 --- a/vendor/bytelyst/react-auth/package.json +++ b/vendor/bytelyst/react-auth/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/react-auth", - "version": "0.1.1", + "version": "0.1.6", "type": "module", "exports": { ".": { @@ -34,4 +34,4 @@ "publishConfig": { "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" } -} \ No newline at end of file +} diff --git a/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx new file mode 100644 index 0000000..303f884 --- /dev/null +++ b/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx @@ -0,0 +1,479 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { createAuthProvider } from '../index.js'; + +// Minimal user type for testing +interface TestUser { + email: string; + name: string; + role: string; + [key: string]: unknown; +} + +// Mock fetch globally +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +// localStorage mock +const store: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) delete store[key]; + }), + length: 0, + key: vi.fn(), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); + +function createTestAuth(overrides?: Partial>[0]>) { + return createAuthProvider({ + storagePrefix: 'test', + loginEndpoint: '/auth/login', + mapLoginResponse: (data: unknown) => { + const d = data as { user: TestUser; accessToken: string; refreshToken: string }; + return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; + }, + ...overrides, + }); +} + +describe('createAuthProvider', () => { + beforeEach(() => { + cleanup(); + localStorageMock.clear(); + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('returns AuthProvider and useAuth', () => { + const result = createTestAuth(); + expect(result.AuthProvider).toBeDefined(); + expect(result.useAuth).toBeDefined(); + expect(typeof result.AuthProvider).toBe('function'); + expect(typeof result.useAuth).toBe('function'); + }); + + it('renders children', () => { + const { AuthProvider } = createTestAuth(); + render( + +
Hello
+
+ ); + expect(screen.getByTestId('child').textContent).toBe('Hello'); + }); + + it('starts unauthenticated with no stored user', () => { + const { AuthProvider, useAuth } = createTestAuth(); + function Display() { + const { user, isAuthenticated, isLoading } = useAuth(); + return ( +
+ {String(isAuthenticated)} + {String(isLoading)} + {user ? user.email : 'none'} +
+ ); + } + render( + + + + ); + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('loading').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe('none'); + }); + + it('restores user from localStorage on mount', () => { + const storedUser: TestUser = { email: 'a@b.com', name: 'Stored', role: 'user' }; + store['test_auth_user'] = JSON.stringify(storedUser); + + const { AuthProvider, useAuth } = createTestAuth(); + function Display() { + const { user, isAuthenticated } = useAuth(); + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); + } + render( + + + + ); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('email').textContent).toBe('a@b.com'); + }); + + it('login stores user and tokens on success', async () => { + const apiResponse = { + user: { email: 'test@example.com', name: 'Test' }, + accessToken: 'at-123', + refreshToken: 'rt-456', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + + function LoginComponent() { + const { login, user, isAuthenticated } = useAuth(); + loginFn = login; + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + + let result: boolean = false; + await act(async () => { + result = await loginFn!('test@example.com', 'pass123'); + }); + + expect(result).toBe(true); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('email').textContent).toBe('test@example.com'); + expect(mockFetch).toHaveBeenCalledWith( + '/api/auth/login', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', password: 'pass123' }), + }) + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'test_auth_user', + expect.stringContaining('test@example.com') + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith('test_access_token', 'at-123'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('test_refresh_token', 'rt-456'); + }); + + it('login returns false on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'Unauthorized' }), + headers: new Headers({ 'content-type': 'application/json' }), + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + + function LoginComponent() { + const { login, isAuthenticated, error } = useAuth(); + loginFn = login; + return ( +
+ {String(isAuthenticated)} + {error ?? 'none'} +
+ ); + } + + render( + + + + ); + + let result: boolean = false; + await act(async () => { + result = await loginFn!('bad@example.com', 'wrong'); + }); + + expect(result).toBe(false); + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('error').textContent).toBe('Unauthorized'); + }); + + it('logout clears user and storage', async () => { + store['test_auth_user'] = JSON.stringify({ email: 'a@b.com', name: 'A', role: 'admin' }); + store['test_access_token'] = 'token'; + store['test_refresh_token'] = 'refresh'; + + const onLogout = vi.fn(); + const { AuthProvider, useAuth } = createTestAuth({ onLogout }); + let logoutFn: () => void; + + function Component() { + const { logout, isAuthenticated } = useAuth(); + logoutFn = logout; + return {String(isAuthenticated)}; + } + + render( + + + + ); + + expect(screen.getByTestId('auth').textContent).toBe('true'); + + act(() => { + logoutFn!(); + }); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_auth_user'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_access_token'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_refresh_token'); + expect(onLogout).toHaveBeenCalledOnce(); + }); + + it('useAuth throws outside AuthProvider', () => { + const { useAuth } = createTestAuth(); + function Bad() { + useAuth(); + return null; + } + expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); + }); + + it('calls onLoginFallback when API fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const fallbackUser: TestUser = { email: 'mock@test.com', name: 'Mock', role: 'user' }; + const onLoginFallback = vi.fn().mockResolvedValue({ + user: fallbackUser, + accessToken: 'fallback-at', + refreshToken: 'fallback-rt', + }); + + const { AuthProvider, useAuth } = createTestAuth({ onLoginFallback }); + let loginFn: (email: string, password: string) => Promise; + + function Component() { + const { login, user } = useAuth(); + loginFn = login; + return {user?.email ?? 'none'}; + } + + render( + + + + ); + + let result = false; + await act(async () => { + result = await loginFn!('mock@test.com', 'pass'); + }); + + expect(result).toBe(true); + expect(onLoginFallback).toHaveBeenCalledWith('mock@test.com', 'pass', expect.any(String)); + expect(screen.getByTestId('email').textContent).toBe('mock@test.com'); + }); + + it('updateUser merges partial updates into user state', async () => { + const apiResponse = { + user: { email: 'test@example.com', name: 'Original', role: 'user' }, + accessToken: 'at-1', + refreshToken: 'rt-1', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + let updateUserFn: (updates: Partial) => void; + + function Component() { + const { login, updateUser, user } = useAuth(); + loginFn = login; + updateUserFn = updateUser; + return ( +
+ {user?.name ?? 'none'} + {user?.role ?? 'none'} +
+ ); + } + + render( + + + + ); + + await act(async () => { + await loginFn!('test@example.com', 'pass'); + }); + expect(screen.getByTestId('name').textContent).toBe('Original'); + + act(() => { + updateUserFn!({ name: 'Updated' }); + }); + + expect(screen.getByTestId('name').textContent).toBe('Updated'); + expect(screen.getByTestId('role').textContent).toBe('user'); + // Verify localStorage was updated too + const storedUser = JSON.parse(store['test_auth_user']); + expect(storedUser.name).toBe('Updated'); + expect(storedUser.role).toBe('user'); + }); + + it('updateUser is a no-op when no user is logged in', () => { + const { AuthProvider, useAuth } = createTestAuth(); + let updateUserFn: (updates: Partial) => void; + + function Component() { + const { updateUser, user } = useAuth(); + updateUserFn = updateUser; + return {user ? 'yes' : 'no'}; + } + + render( + + + + ); + + act(() => { + updateUserFn!({ name: 'Should not crash' }); + }); + + expect(screen.getByTestId('user').textContent).toBe('no'); + }); + + it('onInit provides initial session from external source', () => { + const initUser: TestUser = { email: 'sso@corp.com', name: 'SSO User', role: 'admin' }; + const { AuthProvider, useAuth } = createTestAuth({ + onInit: () => ({ + user: initUser, + accessToken: 'sso-at', + refreshToken: 'sso-rt', + }), + }); + + function Display() { + const { user, isAuthenticated } = useAuth(); + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('email').textContent).toBe('sso@corp.com'); + // Verify tokens were saved + expect(store['test_access_token']).toBe('sso-at'); + expect(store['test_refresh_token']).toBe('sso-rt'); + }); + + it('onInit returning null falls through to localStorage', () => { + store['test_auth_user'] = JSON.stringify({ + email: 'stored@local.com', + name: 'Local', + role: 'user', + }); + + const { AuthProvider, useAuth } = createTestAuth({ + onInit: () => null, + }); + + function Display() { + const { user } = useAuth(); + return {user?.email ?? 'none'}; + } + + render( + + + + ); + + expect(screen.getByTestId('email').textContent).toBe('stored@local.com'); + }); + + it('onInit takes priority over localStorage when it returns a session', () => { + store['test_auth_user'] = JSON.stringify({ + email: 'stored@local.com', + name: 'Local', + role: 'user', + }); + + const { AuthProvider, useAuth } = createTestAuth({ + onInit: () => ({ + user: { email: 'init@override.com', name: 'Init', role: 'admin' }, + accessToken: 'init-at', + refreshToken: 'init-rt', + }), + }); + + function Display() { + const { user } = useAuth(); + return {user?.email ?? 'none'}; + } + + render( + + + + ); + + expect(screen.getByTestId('email').textContent).toBe('init@override.com'); + }); + + it('uses correct storage prefix for keys', () => { + const storedUser: TestUser = { email: 'x@y.com', name: 'X', role: 'viewer' }; + store['custom_auth_user'] = JSON.stringify(storedUser); + + const { AuthProvider, useAuth } = createAuthProvider({ + storagePrefix: 'custom', + loginEndpoint: '/login', + mapLoginResponse: (d: unknown) => + d as { user: TestUser; accessToken: string; refreshToken: string }, + }); + + function Display() { + const { user } = useAuth(); + return {user?.email ?? 'none'}; + } + + render( + + + + ); + + expect(screen.getByTestId('email').textContent).toBe('x@y.com'); + }); +}); diff --git a/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx new file mode 100644 index 0000000..26c687e --- /dev/null +++ b/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx @@ -0,0 +1,339 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import { createAuthProvider } from '../index.js'; + +interface TestUser { + email: string; + name: string; + role: string; + [key: string]: unknown; +} + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +const store: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) delete store[key]; + }), + length: 0, + key: vi.fn(), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); + +function createTestAuth(overrides?: Partial>[0]>) { + return createAuthProvider({ + storagePrefix: 'sa', + loginEndpoint: '/auth/login', + mapLoginResponse: (data: unknown) => { + const d = data as { user: TestUser; accessToken: string; refreshToken: string }; + return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; + }, + ...overrides, + }); +} + +describe('react-auth SmartAuth features', () => { + beforeEach(() => { + cleanup(); + localStorageMock.clear(); + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + // ── Phase 1C: loginWithGoogle ────────────────────── + + it('loginWithGoogle sets user on success', async () => { + const apiResponse = { + user: { email: 'g@gmail.com', name: 'Google User', role: 'user' }, + accessToken: 'g-at', + refreshToken: 'g-rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => apiResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginWithGoogleFn: (idToken: string) => Promise; + + function Component() { + const { loginWithGoogle, user, isAuthenticated } = useAuth(); + loginWithGoogleFn = loginWithGoogle; + return ( +
+ {String(isAuthenticated)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + + let result = false; + await act(async () => { + result = await loginWithGoogleFn!('google-id-token'); + }); + + expect(result).toBe(true); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('email').textContent).toBe('g@gmail.com'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'g-at'); + }); + + it('loginWithGoogle returns false on API failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'Invalid token' }), + headers: new Headers({ 'content-type': 'application/json' }), + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginWithGoogleFn: (idToken: string) => Promise; + + function Component() { + const { loginWithGoogle, isAuthenticated, error } = useAuth(); + loginWithGoogleFn = loginWithGoogle; + return ( +
+ {String(isAuthenticated)} + {error ?? 'none'} +
+ ); + } + + render( + + + + ); + + let result = true; + await act(async () => { + result = await loginWithGoogleFn!('bad-token'); + }); + + expect(result).toBe(false); + expect(screen.getByTestId('auth').textContent).toBe('false'); + }); + + // ── Phase 2D: MFA challenge flow ────────────────── + + it('login triggers MFA state when mfaRequired returned', async () => { + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'challenge-xyz', + methods: ['totp'], + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mfaResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const onMfaRequired = vi.fn(); + const { AuthProvider, useAuth } = createTestAuth({ onMfaRequired }); + let loginFn: (email: string, password: string) => Promise; + + function Component() { + const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth(); + loginFn = login; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} + {mfaChallenge ?? 'none'} + {mfaMethods.join(',') || 'none'} +
+ ); + } + + render( + + + + ); + + let result = true; + await act(async () => { + result = await loginFn!('user@test.com', 'pass'); + }); + + // Login returns false (MFA required, not yet authenticated) + expect(result).toBe(false); + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('mfa').textContent).toBe('true'); + expect(screen.getByTestId('challenge').textContent).toBe('challenge-xyz'); + expect(screen.getByTestId('methods').textContent).toBe('totp'); + expect(onMfaRequired).toHaveBeenCalledWith('challenge-xyz', ['totp']); + }); + + it('verifyMfa completes login after MFA challenge', async () => { + // Step 1: login triggers MFA + const mfaResponse = { + mfaRequired: true, + mfaChallenge: 'challenge-abc', + methods: ['totp'], + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mfaResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise; + + function Component() { + const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth(); + loginFn = login; + verifyMfaFn = verifyMfa; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} + {user?.email ?? 'none'} +
+ ); + } + + render( + + + + ); + + await act(async () => { + await loginFn!('user@test.com', 'pass'); + }); + + expect(screen.getByTestId('mfa').textContent).toBe('true'); + expect(screen.getByTestId('auth').textContent).toBe('false'); + + // Step 2: verify MFA + const verifyResponse = { + user: { email: 'user@test.com', name: 'Test', role: 'user' }, + accessToken: 'mfa-at', + refreshToken: 'mfa-rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => verifyResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + let verifyResult = false; + await act(async () => { + verifyResult = await verifyMfaFn!('123456', 'totp'); + }); + + expect(verifyResult).toBe(true); + expect(screen.getByTestId('auth').textContent).toBe('true'); + expect(screen.getByTestId('mfa').textContent).toBe('false'); + expect(screen.getByTestId('email').textContent).toBe('user@test.com'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'mfa-at'); + }); + + // ── Phase 1C: Provider management ───────────────── + + it('providers starts empty and exposes link/unlink', () => { + const { AuthProvider, useAuth } = createTestAuth(); + + function Component() { + const { providers, linkProvider, unlinkProvider, refreshProviders } = useAuth(); + return ( +
+ {providers.length} + {String(typeof linkProvider === 'function')} + {String(typeof unlinkProvider === 'function')} + {String(typeof refreshProviders === 'function')} +
+ ); + } + + render( + + + + ); + + expect(screen.getByTestId('count').textContent).toBe('0'); + expect(screen.getByTestId('hasLink').textContent).toBe('true'); + expect(screen.getByTestId('hasUnlink').textContent).toBe('true'); + expect(screen.getByTestId('hasRefresh').textContent).toBe('true'); + }); + + // ── Logout clears SmartAuth state ───────────────── + + it('logout clears providers and MFA state', async () => { + // Login first + const loginResponse = { + user: { email: 'a@b.com', name: 'A', role: 'user' }, + accessToken: 'at', + refreshToken: 'rt', + }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => loginResponse, + headers: new Headers({ 'content-type': 'application/json' }), + status: 200, + }); + + const { AuthProvider, useAuth } = createTestAuth(); + let loginFn: (email: string, password: string) => Promise; + let logoutFn: () => void; + + function Component() { + const { login, logout, isAuthenticated, mfaRequired } = useAuth(); + loginFn = login; + logoutFn = logout; + return ( +
+ {String(isAuthenticated)} + {String(mfaRequired)} +
+ ); + } + + render( + + + + ); + + await act(async () => { + await loginFn!('a@b.com', 'pass'); + }); + + expect(screen.getByTestId('auth').textContent).toBe('true'); + + act(() => { + logoutFn!(); + }); + + expect(screen.getByTestId('auth').textContent).toBe('false'); + expect(screen.getByTestId('mfa').textContent).toBe('false'); + }); +}); diff --git a/vendor/bytelyst/react-auth/src/auth-context.tsx b/vendor/bytelyst/react-auth/src/auth-context.tsx new file mode 100644 index 0000000..b2be03c --- /dev/null +++ b/vendor/bytelyst/react-auth/src/auth-context.tsx @@ -0,0 +1,551 @@ +'use client'; + +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, + type ReactNode, +} from 'react'; +import { createApiClient } from '@bytelyst/api-client'; +import type { AuthConfig, AuthContextValue, AuthProviderInfo, BaseUser } from './types.js'; + +/** + * Create a typed auth provider + hook for a specific user type. + * + * Supports the full auth lifecycle: login, register, forgot password, + * change password, delete account, and automatic token refresh. + * + * @example + * ```tsx + * const { AuthProvider, useAuth } = createAuthProvider({ + * storagePrefix: "admin", + * loginEndpoint: "/auth/login", + * registerEndpoint: "/auth/register", + * forgotPasswordEndpoint: "/auth/forgot-password", + * changePasswordEndpoint: "/auth/change-password", + * deleteAccountEndpoint: "/auth/delete-account", + * refreshEndpoint: "/auth/refresh", + * mapLoginResponse: (data) => ({ + * user: data.user, + * accessToken: data.accessToken, + * refreshToken: data.refreshToken, + * }), + * }); + * ``` + */ +export function createAuthProvider(config: AuthConfig) { + const { + baseUrl: configBaseUrl = '/api', + storagePrefix, + loginEndpoint, + registerEndpoint, + forgotPasswordEndpoint, + changePasswordEndpoint, + deleteAccountEndpoint, + refreshEndpoint, + refreshIntervalMs = 45 * 60 * 1000, + mapLoginResponse, + onLoginFallback, + onInit, + onLogout, + oauthEndpoint = '/auth/oauth', + providersEndpoint = '/auth/providers', + linkProviderEndpoint = '/auth/providers/link', + mfaVerifyEndpoint = '/auth/mfa/verify', + onMfaRequired, + productId: configProductId, + } = config; + + const USER_KEY = `${storagePrefix}_auth_user`; + const TOKEN_KEY = `${storagePrefix}_access_token`; + const REFRESH_KEY = `${storagePrefix}_refresh_token`; + + const AuthContext = createContext | null>(null); + + function getStoredUser(): TUser | null { + if (typeof window === 'undefined') return null; + try { + const stored = localStorage.getItem(USER_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } + } + + function saveSession(user: TUser, accessToken: string, refreshToken: string) { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_KEY, refreshToken); + } + + function clearSession() { + localStorage.removeItem(USER_KEY); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); + } + + function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(() => { + // Allow onInit to provide an initial session (e.g. from SSO cookies) + if (onInit) { + const initResult = onInit(); + if (initResult) { + saveSession(initResult.user, initResult.accessToken, initResult.refreshToken); + return initResult.user; + } + } + return getStoredUser(); + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [providers, setProviders] = useState([]); + const [mfaRequired, setMfaRequired] = useState(false); + const [mfaMethods, setMfaMethods] = useState([]); + const [mfaChallenge, setMfaChallenge] = useState(null); + const refreshTimerRef = useRef | null>(null); + + const api = createApiClient({ + baseUrl: configBaseUrl, + getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null), + }); + + const clearMessages = useCallback(() => { + setError(null); + setSuccess(null); + }, []); + + // ── Token refresh ────────────────────────────── + + const refreshAccessToken = useCallback(async () => { + if (!refreshEndpoint) return; + const rt = typeof window !== 'undefined' ? localStorage.getItem(REFRESH_KEY) : null; + if (!rt) return; + + try { + const data = await api.fetch<{ accessToken: string; refreshToken: string }>( + refreshEndpoint, + { method: 'POST', body: JSON.stringify({ refreshToken: rt }) } + ); + localStorage.setItem(TOKEN_KEY, data.accessToken); + localStorage.setItem(REFRESH_KEY, data.refreshToken); + } catch { + // Token expired — force logout + setUser(null); + clearSession(); + onLogout?.(); + } + }, [api]); + + useEffect(() => { + if (!user || !refreshEndpoint) return; + refreshTimerRef.current = setInterval(refreshAccessToken, refreshIntervalMs); + return () => { + if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); + }; + }, [user, refreshAccessToken, refreshIntervalMs]); + + // ── MFA challenge helper ───────────────────────── + + function handleMfaChallenge(data: Record): boolean { + if (data && typeof data === 'object' && 'mfaRequired' in data && data.mfaRequired === true) { + const challenge = data.mfaChallenge as string; + const methods = data.methods as string[]; + setMfaRequired(true); + setMfaChallenge(challenge); + setMfaMethods(methods ?? []); + onMfaRequired?.(challenge, methods ?? []); + return true; + } + return false; + } + + // ── Login ────────────────────────────────────── + + const login = useCallback( + async (email: string, password: string) => { + setIsLoading(true); + setError(null); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + try { + const { data, error: fetchError } = await api.safeFetch(loginEndpoint, { + method: 'POST', + body: JSON.stringify( + configProductId + ? { email, password, productId: configProductId } + : { email, password } + ), + }); + + if (data && !fetchError) { + if (handleMfaChallenge(data as Record)) { + return false; + } + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + return true; + } + + if (fetchError && onLoginFallback) { + const fallback = await onLoginFallback(email, password, fetchError); + if (fallback) { + setUser(fallback.user); + saveSession(fallback.user, fallback.accessToken, fallback.refreshToken); + return true; + } + } + + setError(fetchError || 'Login failed'); + return false; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + // ── Register ─────────────────────────────────── + + const register = useCallback( + async (email: string, password: string, displayName: string) => { + if (!registerEndpoint) { + setError('Registration not supported'); + return false; + } + setIsLoading(true); + setError(null); + try { + const { data, error: fetchError } = await api.safeFetch(registerEndpoint, { + method: 'POST', + body: JSON.stringify( + configProductId + ? { email, password, displayName, productId: configProductId } + : { email, password, displayName } + ), + }); + + if (data && !fetchError) { + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + return true; + } + + setError(fetchError || 'Registration failed'); + return false; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + // ── Social login (Phase 1C) ──────────────────── + + const loginWithOAuth = useCallback( + async (provider: string, idToken: string) => { + setIsLoading(true); + setError(null); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + try { + const oauthBody: Record = { idToken }; + if (configProductId) oauthBody.productId = configProductId; + const { data, error: fetchError } = await api.safeFetch( + `${oauthEndpoint}/${provider}`, + { method: 'POST', body: JSON.stringify(oauthBody) } + ); + + if (data && !fetchError) { + if (handleMfaChallenge(data as Record)) { + return false; + } + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + return true; + } + + setError(fetchError || `${provider} login failed`); + return false; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + const loginWithGoogle = useCallback( + (idToken: string) => loginWithOAuth('google', idToken), + [loginWithOAuth] + ); + + const loginWithMicrosoft = useCallback( + (idToken: string) => loginWithOAuth('microsoft', idToken), + [loginWithOAuth] + ); + + const loginWithApple = useCallback( + (idToken: string) => loginWithOAuth('apple', idToken), + [loginWithOAuth] + ); + + // ── Provider management (Phase 1C) ──────────── + + const refreshProviders = useCallback(async () => { + try { + const data = await api.fetch<{ providers: AuthProviderInfo[] }>(providersEndpoint, { + method: 'GET', + }); + setProviders(data.providers ?? []); + } catch { + // non-fatal — providers list is supplementary + } + }, [api]); + + const linkProvider = useCallback( + async (provider: string, idToken: string) => { + setIsLoading(true); + setError(null); + try { + const { error: fetchError } = await api.safeFetch(linkProviderEndpoint, { + method: 'POST', + body: JSON.stringify({ provider, idToken }), + }); + if (fetchError) { + setError(fetchError); + return false; + } + await refreshProviders(); + return true; + } finally { + setIsLoading(false); + } + }, + [api, refreshProviders] + ); + + const unlinkProvider = useCallback( + async (provider: string) => { + setIsLoading(true); + setError(null); + try { + const { error: fetchError } = await api.safeFetch( + `${providersEndpoint}/${provider}`, + { method: 'DELETE' } + ); + if (fetchError) { + setError(fetchError); + return false; + } + await refreshProviders(); + return true; + } finally { + setIsLoading(false); + } + }, + [api, refreshProviders] + ); + + // ── MFA verify (Phase 2D) ───────────────────── + + const verifyMfa = useCallback( + async (code: string, method: 'totp' | 'recovery') => { + if (!mfaChallenge) { + setError('No MFA challenge in progress'); + return false; + } + setIsLoading(true); + setError(null); + try { + const { data, error: fetchError } = await api.safeFetch(mfaVerifyEndpoint, { + method: 'POST', + body: JSON.stringify({ challengeToken: mfaChallenge, code, method }), + }); + + if (data && !fetchError) { + const mapped = mapLoginResponse(data); + setUser(mapped.user); + saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + return true; + } + + setError(fetchError || 'MFA verification failed'); + return false; + } finally { + setIsLoading(false); + } + }, + [api, mfaChallenge] + ); + + // ── Logout ───────────────────────────────────── + + const logout = useCallback(() => { + setUser(null); + clearSession(); + setProviders([]); + setMfaRequired(false); + setMfaChallenge(null); + setMfaMethods([]); + if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); + onLogout?.(); + }, []); + + // ── Forgot password ──────────────────────────── + + const forgotPassword = useCallback( + async (email: string) => { + if (!forgotPasswordEndpoint) { + setError('Forgot password not supported'); + return false; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const { error: fetchError } = await api.safeFetch<{ message: string }>( + forgotPasswordEndpoint, + { method: 'POST', body: JSON.stringify({ email }) } + ); + if (fetchError) { + setError(fetchError); + return false; + } + setSuccess('If that email exists, a reset link has been sent.'); + return true; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + // ── Change password ──────────────────────────── + + const changePassword = useCallback( + async (currentPassword: string, newPassword: string) => { + if (!changePasswordEndpoint) { + setError('Change password not supported'); + return false; + } + setIsLoading(true); + setError(null); + setSuccess(null); + try { + const { error: fetchError } = await api.safeFetch<{ message: string }>( + changePasswordEndpoint, + { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) } + ); + if (fetchError) { + setError(fetchError); + return false; + } + setSuccess('Password changed successfully.'); + return true; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + // ── Update user (local state + localStorage) ── + + const updateUser = useCallback((updates: Partial) => { + setUser(prev => { + if (!prev) return null; + const updated = { ...prev, ...updates }; + localStorage.setItem(USER_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + // ── Delete account ───────────────────────────── + + const deleteAccount = useCallback( + async (password: string) => { + if (!deleteAccountEndpoint) { + setError('Account deletion not supported'); + return false; + } + setIsLoading(true); + setError(null); + try { + const { error: fetchError } = await api.safeFetch<{ message: string }>( + deleteAccountEndpoint, + { method: 'DELETE', body: JSON.stringify({ password }) } + ); + if (fetchError) { + setError(fetchError); + return false; + } + setUser(null); + clearSession(); + if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); + onLogout?.(); + return true; + } finally { + setIsLoading(false); + } + }, + [api] + ); + + return ( + + {children} + + ); + } + + function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return ctx; + } + + return { AuthProvider, useAuth }; +} diff --git a/vendor/bytelyst/react-auth/src/index.ts b/vendor/bytelyst/react-auth/src/index.ts new file mode 100644 index 0000000..424c649 --- /dev/null +++ b/vendor/bytelyst/react-auth/src/index.ts @@ -0,0 +1,8 @@ +export { createAuthProvider } from './auth-context.js'; +export type { + AuthProviderInfo, + BaseUser, + AuthContextValue, + AuthConfig, + LoginResult, +} from './types.js'; diff --git a/vendor/bytelyst/react-auth/src/types.ts b/vendor/bytelyst/react-auth/src/types.ts new file mode 100644 index 0000000..029267d --- /dev/null +++ b/vendor/bytelyst/react-auth/src/types.ts @@ -0,0 +1,89 @@ +export interface BaseUser { + email: string; + name: string; + role: string; + [key: string]: unknown; +} + +export interface AuthProviderInfo { + provider: string; + email: string; + linkedAt: string; + lastUsedAt: string | null; +} + +export interface AuthContextValue { + user: TUser | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + success: string | null; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; + logout: () => void; + forgotPassword: (email: string) => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; + deleteAccount: (password: string) => Promise; + updateUser: (updates: Partial) => void; + clearMessages: () => void; + + // ── SmartAuth: Social login (Phase 1C) ──────────── + loginWithGoogle: (idToken: string) => Promise; + loginWithMicrosoft: (idToken: string) => Promise; + loginWithApple: (idToken: string) => Promise; + + // ── SmartAuth: Provider management (Phase 1C) ───── + providers: AuthProviderInfo[]; + linkProvider: (provider: string, idToken: string) => Promise; + unlinkProvider: (provider: string) => Promise; + refreshProviders: () => Promise; + + // ── SmartAuth: MFA state (Phase 2D) ─────────────── + mfaRequired: boolean; + mfaMethods: string[]; + mfaChallenge: string | null; + verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise; +} + +export interface LoginResult { + user: TUser; + accessToken: string; + refreshToken: string; +} + +export interface AuthConfig { + /** Base URL for auth API calls. Default: '/api'. */ + baseUrl?: string; + /** Product identifier sent with OAuth requests. */ + productId?: string; + storagePrefix: string; + loginEndpoint: string; + registerEndpoint?: string; + forgotPasswordEndpoint?: string; + changePasswordEndpoint?: string; + deleteAccountEndpoint?: string; + refreshEndpoint?: string; + /** Token refresh interval in ms. Default: 45 * 60 * 1000 (45 minutes). */ + refreshIntervalMs?: number; + mapLoginResponse: (data: unknown) => LoginResult; + onLoginFallback?: ( + email: string, + password: string, + error: string + ) => Promise | null>; + /** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */ + onInit?: () => LoginResult | null; + onLogout?: () => void; + + // ── SmartAuth endpoint config (Phase 1C+) ───────── + /** Endpoint for OAuth social login. Default: '/auth/oauth'. Provider appended as path segment. */ + oauthEndpoint?: string; + /** Endpoint for listing providers. Default: '/auth/providers'. */ + providersEndpoint?: string; + /** Endpoint for linking a provider. Default: '/auth/providers/link'. */ + linkProviderEndpoint?: string; + /** Endpoint for MFA verification. Default: '/auth/mfa/verify'. */ + mfaVerifyEndpoint?: string; + /** Callback when MFA is required after login. Receives challenge token and methods. */ + onMfaRequired?: (challenge: string, methods: string[]) => void; +} diff --git a/vendor/bytelyst/react-auth/tsconfig.json b/vendor/bytelyst/react-auth/tsconfig.json new file mode 100644 index 0000000..4447784 --- /dev/null +++ b/vendor/bytelyst/react-auth/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/react-auth/vitest.config.ts b/vendor/bytelyst/react-auth/vitest.config.ts new file mode 100644 index 0000000..9eaeb03 --- /dev/null +++ b/vendor/bytelyst/react-auth/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Use happy-dom to avoid jsdom's heavy dependency chain and ESM/CJS edge cases. + // This package only needs a minimal DOM + localStorage for unit tests. + environment: 'happy-dom', + pool: 'forks', + }, +}); diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 new file mode 100644 index 0000000..41a9cd1 Binary files /dev/null and b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 differ diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c new file mode 100644 index 0000000..7cff770 Binary files /dev/null and b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c differ diff --git a/vendor/bytelyst/react-native-platform-sdk/package.json b/vendor/bytelyst/react-native-platform-sdk/package.json new file mode 100644 index 0000000..a44d670 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/package.json @@ -0,0 +1,66 @@ +{ + "name": "@bytelyst/react-native-platform-sdk", + "version": "1.0.0", + "description": "React Native SDK for ByteLyst platform services", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./auth": { + "import": "./dist/auth.js", + "types": "./dist/auth.d.ts" + }, + "./telemetry": { + "import": "./dist/telemetry.js", + "types": "./dist/telemetry.d.ts" + }, + "./feature-flags": { + "import": "./dist/feature-flags.js", + "types": "./dist/feature-flags.d.ts" + }, + "./kill-switch": { + "import": "./dist/kill-switch.js", + "types": "./dist/kill-switch.d.ts" + }, + "./broadcasts": { + "import": "./dist/broadcasts.js", + "types": "./dist/broadcasts.d.ts" + }, + "./surveys": { + "import": "./dist/surveys.js", + "types": "./dist/surveys.d.ts" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks", + "lint": "eslint src/**/*.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "peerDependencies": { + "react": ">=18.0.0", + "react-native": ">=0.72.0", + "expo": ">=49.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "react-native", + "bytelyst", + "platform", + "expo", + "mobile" + ], + "license": "MIT", + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth.ts new file mode 100644 index 0000000..9ec3a88 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/auth.ts @@ -0,0 +1 @@ +export { useAuth, AuthProvider, type AuthState, type AuthContextType } from './auth/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts new file mode 100644 index 0000000..a5af012 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts @@ -0,0 +1,208 @@ +/** + * Auth module — React context + hook for authentication in React Native apps. + */ + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { PlatformSDK } from '../core.js'; + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + userId: string | null; + email: string | null; + error: string | null; +} + +export interface AuthContextType extends AuthState { + login: (email: string, password: string) => Promise; + register: (email: string, password: string, displayName: string) => Promise; + loginWithGoogle: (idToken: string) => Promise; + loginWithApple: (idToken: string) => Promise; + logout: () => Promise; + refreshSession: () => Promise; +} + +const AuthContext = createContext(null); + +export function useAuth(): AuthContextType { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} + +interface AuthProviderProps { + sdk: PlatformSDK; + children: React.ReactNode; + /** Called when tokens are received — persist to secure storage */ + onTokens?: (access: string, refresh: string) => void; + /** Called on logout — clear secure storage */ + onLogout?: () => void; +} + +export function AuthProvider({ + sdk, + children, + onTokens, + onLogout, +}: AuthProviderProps): React.JSX.Element { + const [state, setState] = useState({ + isAuthenticated: false, + isLoading: true, + userId: null, + email: null, + error: null, + }); + + const handleTokenResponse = useCallback( + async (res: Response) => { + if (!res.ok) { + const body = (await res.json().catch(() => ({ message: 'Login failed' }))) as { + message?: string; + }; + throw new Error(body.message ?? `HTTP ${res.status}`); + } + const data = (await res.json()) as { + accessToken?: string; + refreshToken?: string; + user?: { id?: string; email?: string }; + }; + if (data.accessToken && data.refreshToken) { + onTokens?.(data.accessToken, data.refreshToken); + } + setState({ + isAuthenticated: true, + isLoading: false, + userId: data.user?.id ?? null, + email: data.user?.email ?? null, + error: null, + }); + }, + [onTokens] + ); + + const login = useCallback( + async (email: string, password: string) => { + setState(s => ({ ...s, isLoading: true, error: null })); + try { + const res = await sdk.fetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password, productId: sdk.config.productId }), + }); + await handleTokenResponse(res); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Login failed'; + setState(s => ({ ...s, isLoading: false, error: msg })); + } + }, + [sdk, handleTokenResponse] + ); + + const register = useCallback( + async (email: string, password: string, displayName: string) => { + setState(s => ({ ...s, isLoading: true, error: null })); + try { + const res = await sdk.fetch('/auth/register', { + method: 'POST', + body: JSON.stringify({ + email, + password, + displayName, + productId: sdk.config.productId, + }), + }); + await handleTokenResponse(res); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Registration failed'; + setState(s => ({ ...s, isLoading: false, error: msg })); + } + }, + [sdk, handleTokenResponse] + ); + + const loginWithGoogle = useCallback( + async (idToken: string) => { + setState(s => ({ ...s, isLoading: true, error: null })); + try { + const res = await sdk.fetch('/auth/oauth/google', { + method: 'POST', + body: JSON.stringify({ idToken }), + }); + await handleTokenResponse(res); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Google login failed'; + setState(s => ({ ...s, isLoading: false, error: msg })); + } + }, + [sdk, handleTokenResponse] + ); + + const loginWithApple = useCallback( + async (idToken: string) => { + setState(s => ({ ...s, isLoading: true, error: null })); + try { + const res = await sdk.fetch('/auth/oauth/apple', { + method: 'POST', + body: JSON.stringify({ idToken }), + }); + await handleTokenResponse(res); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Apple login failed'; + setState(s => ({ ...s, isLoading: false, error: msg })); + } + }, + [sdk, handleTokenResponse] + ); + + const logout = useCallback(async () => { + try { + await sdk.fetch('/auth/logout', { method: 'POST' }); + } catch { + /* best-effort */ + } + onLogout?.(); + setState({ + isAuthenticated: false, + isLoading: false, + userId: null, + email: null, + error: null, + }); + }, [sdk, onLogout]); + + const refreshSession = useCallback(async () => { + setState(s => ({ ...s, isLoading: true })); + try { + const res = await sdk.fetch('/auth/me'); + if (res.ok) { + const data = (await res.json()) as { id?: string; email?: string }; + setState({ + isAuthenticated: true, + isLoading: false, + userId: data.id ?? null, + email: data.email ?? null, + error: null, + }); + } else { + setState(s => ({ ...s, isAuthenticated: false, isLoading: false })); + } + } catch { + setState(s => ({ ...s, isLoading: false })); + } + }, [sdk]); + + useEffect(() => { + refreshSession(); + }, [refreshSession]); + + const value: AuthContextType = { + ...state, + login, + register, + loginWithGoogle, + loginWithApple, + logout, + refreshSession, + }; + + return React.createElement(AuthContext.Provider, { value }, children); +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts new file mode 100644 index 0000000..2dfce29 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts @@ -0,0 +1,7 @@ +export { + useBroadcasts, + BroadcastProvider, + InAppMessageBanner, + BroadcastModal, + type InAppMessage, +} from './broadcasts/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts new file mode 100644 index 0000000..3d29d96 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts @@ -0,0 +1,125 @@ +/** + * Broadcasts module — React context + hook for in-app messages in React Native apps. + */ + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { PlatformSDK } from '../core.js'; + +export interface InAppMessage { + id: string; + title: string; + body: string; + type: 'info' | 'warning' | 'critical'; + action?: { label: string; url: string }; + dismissible: boolean; + expiresAt?: string; +} + +interface BroadcastContextType { + messages: InAppMessage[]; + dismiss: (id: string) => void; + refresh: () => Promise; +} + +const BroadcastContext = createContext(null); + +export function useBroadcasts(): BroadcastContextType { + const ctx = useContext(BroadcastContext); + if (!ctx) throw new Error('useBroadcasts must be used within a BroadcastProvider'); + return ctx; +} + +interface BroadcastProviderProps { + sdk: PlatformSDK; + /** Poll interval in ms (default: 300000 = 5 min) */ + pollInterval?: number; + children: React.ReactNode; +} + +export function BroadcastProvider({ + sdk, + pollInterval = 300_000, + children, +}: BroadcastProviderProps): React.JSX.Element { + const [messages, setMessages] = useState([]); + + const refresh = useCallback(async () => { + try { + const res = await sdk.fetch('/broadcasts'); + if (!res.ok) return; + const data = (await res.json()) as { + messages?: Array<{ + id: string; + title: string; + body: string; + ctaText?: string; + ctaUrl?: string; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + dismissible?: boolean; + expiresAt?: string; + }>; + }; + const raw = data.messages ?? []; + const mapped: InAppMessage[] = raw.map(m => ({ + id: m.id, + title: m.title, + body: m.body, + type: + m.priority === 'urgent' ? 'critical' : m.priority === 'high' ? 'warning' : 'info', + action: + m.ctaText && m.ctaUrl ? { label: m.ctaText, url: m.ctaUrl } : undefined, + dismissible: m.dismissible !== false, + expiresAt: m.expiresAt, + })); + setMessages(mapped); + } catch { + /* silent */ + } + }, [sdk]); + + const dismiss = useCallback( + (id: string) => { + setMessages(prev => prev.filter(m => m.id !== id)); + sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {}); + }, + [sdk] + ); + + useEffect(() => { + refresh(); + const id = setInterval(refresh, pollInterval); + return () => clearInterval(id); + }, [refresh, pollInterval]); + + const value: BroadcastContextType = { messages, dismiss, refresh }; + return React.createElement(BroadcastContext.Provider, { value }, children); +} + +// MARK: - UI Components + +interface InAppMessageBannerProps { + message: InAppMessage; + onDismiss: () => void; +} + +/** + * Placeholder banner component — product apps should implement their own + * styled version using this as a reference. Returns null (render-only hook). + */ +export function InAppMessageBanner(_props: InAppMessageBannerProps): React.JSX.Element | null { + // Product apps implement their own styled component + return null; +} + +interface BroadcastModalProps { + message: InAppMessage | null; + onDismiss: () => void; +} + +/** + * Placeholder modal component — product apps should implement their own + * styled version. Returns null. + */ +export function BroadcastModal(_props: BroadcastModalProps): React.JSX.Element | null { + return null; +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/core.ts b/vendor/bytelyst/react-native-platform-sdk/src/core.ts new file mode 100644 index 0000000..b1e3c4f --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/core.ts @@ -0,0 +1,40 @@ +/** + * Core SDK factory — creates and configures platform clients for React Native. + */ + +export interface PlatformSDKConfig { + /** Platform-service base URL (e.g. https://api.bytelyst.com) */ + baseURL: string; + /** Product ID (e.g. 'nomgap', 'flowmonk') */ + productId: string; + /** Function that returns the current access token */ + getAccessToken: () => string | null; +} + +export interface PlatformSDK { + config: PlatformSDKConfig; + /** Generic authenticated fetch against platform-service */ + fetch: (path: string, init?: RequestInit) => Promise; +} + +/** + * Create a configured platform SDK instance. + * Pass to providers to wire up auth, telemetry, flags, etc. + */ +export function createRNPlatformSDK(config: PlatformSDKConfig): PlatformSDK { + const platformFetch = async (path: string, init?: RequestInit): Promise => { + const url = `${config.baseURL}${path}`; + const token = config.getAccessToken(); + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': config.productId, + ...((init?.headers as Record) ?? {}), + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return globalThis.fetch(url, { ...init, headers }); + }; + + return { config, fetch: platformFetch }; +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts new file mode 100644 index 0000000..15f9fc3 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts @@ -0,0 +1 @@ +export { useFeatureFlags, FeatureFlagProvider, type FeatureFlag } from './feature-flags/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts new file mode 100644 index 0000000..77825ac --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts @@ -0,0 +1,89 @@ +/** + * Feature Flags module — React context + hook for feature flags in React Native apps. + */ + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { PlatformSDK } from '../core.js'; + +export interface FeatureFlag { + key: string; + enabled: boolean; + value?: unknown; +} + +interface FeatureFlagContextType { + flags: Map; + isEnabled: (key: string) => boolean; + getValue: (key: string, fallback: T) => T; + refresh: () => Promise; +} + +const FeatureFlagContext = createContext(null); + +export function useFeatureFlags(): FeatureFlagContextType { + const ctx = useContext(FeatureFlagContext); + if (!ctx) throw new Error('useFeatureFlags must be used within a FeatureFlagProvider'); + return ctx; +} + +interface FeatureFlagProviderProps { + sdk: PlatformSDK; + /** Poll interval in ms (default: 60000) */ + pollInterval?: number; + /** Optional user id for targeted flag evaluation (GET /flags/poll). */ + userId?: string | null; + children: React.ReactNode; +} + +export function FeatureFlagProvider({ + sdk, + pollInterval = 60_000, + userId, + children, +}: FeatureFlagProviderProps): React.JSX.Element { + const [flags, setFlags] = useState>(new Map()); + + const refresh = useCallback(async () => { + try { + const qs = new URLSearchParams({ platform: 'mobile' }); + if (userId) qs.set('userId', userId); + const res = await sdk.fetch(`/flags/poll?${qs.toString()}`); + if (res.ok) { + const data = (await res.json()) as { flags?: Record }; + const map = new Map(); + const raw = data.flags ?? {}; + for (const [key, enabled] of Object.entries(raw)) { + map.set(key, { key, enabled, value: enabled }); + } + setFlags(map); + } + } catch { + /* fail-open: keep existing flags */ + } + }, [sdk, userId]); + + const isEnabled = useCallback( + (key: string): boolean => { + return flags.get(key)?.enabled ?? false; + }, + [flags] + ); + + const getValue = useCallback( + (key: string, fallback: T): T => { + const flag = flags.get(key); + if (!flag?.enabled) return fallback; + return (flag.value as T) ?? fallback; + }, + [flags] + ); + + useEffect(() => { + refresh(); + const id = setInterval(refresh, pollInterval); + return () => clearInterval(id); + }, [refresh, pollInterval]); + + const value: FeatureFlagContextType = { flags, isEnabled, getValue, refresh }; + return React.createElement(FeatureFlagContext.Provider, { value }, children); +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/index.ts new file mode 100644 index 0000000..5a5e533 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/index.ts @@ -0,0 +1,55 @@ +/** + * ByteLyst React Native Platform SDK + * + * Provides platform services for React Native/Expo apps: + * - Authentication + * - Telemetry + * - Feature Flags + * - Kill Switch + * - Broadcasts & Surveys + */ + +export { createRNPlatformSDK } from './core.js'; + +// Re-exports from sub-modules +export { + useAuth, + AuthProvider, + type AuthState, + type AuthContextType, +} from './auth/index.js'; + +export { + useTelemetry, + TelemetryProvider, + type TelemetryEvent, + type TelemetryConfig, +} from './telemetry/index.js'; + +export { + useFeatureFlags, + FeatureFlagProvider, + type FeatureFlag, +} from './feature-flags/index.js'; + +export { + useKillSwitch, + KillSwitchProvider, + type KillSwitchState, +} from './kill-switch/index.js'; + +export { + useBroadcasts, + BroadcastProvider, + InAppMessageBanner, + BroadcastModal, + type InAppMessage, +} from './broadcasts/index.js'; + +export { + useSurveys, + SurveyProvider, + SurveyModal, + type ActiveSurvey, + type Question, +} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts new file mode 100644 index 0000000..ad836e4 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts @@ -0,0 +1 @@ +export { useKillSwitch, KillSwitchProvider, type KillSwitchState } from './kill-switch/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts new file mode 100644 index 0000000..b9f5f16 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts @@ -0,0 +1,76 @@ +/** + * Kill Switch module — React context + hook for kill switch in React Native apps. + * Fail-open: if the check fails, the app is assumed to be enabled. + */ + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { PlatformSDK } from '../core.js'; + +export interface KillSwitchState { + disabled: boolean; + reason?: string; + isLoading: boolean; +} + +interface KillSwitchContextType extends KillSwitchState { + check: () => Promise; +} + +const KillSwitchContext = createContext(null); + +export function useKillSwitch(): KillSwitchContextType { + const ctx = useContext(KillSwitchContext); + if (!ctx) throw new Error('useKillSwitch must be used within a KillSwitchProvider'); + return ctx; +} + +interface KillSwitchProviderProps { + sdk: PlatformSDK; + /** Poll interval in ms (default: 300000 = 5 min) */ + pollInterval?: number; + children: React.ReactNode; +} + +export function KillSwitchProvider({ + sdk, + pollInterval = 300_000, + children, +}: KillSwitchProviderProps): React.JSX.Element { + const [state, setState] = useState({ + disabled: false, + isLoading: true, + }); + + const check = useCallback(async () => { + try { + const res = await sdk.fetch('/settings/kill-switch'); + if (res.ok) { + const data = (await res.json()) as { + disabled?: boolean; + reason?: string; + message?: string; + }; + setState({ + disabled: data.disabled ?? false, + reason: data.reason ?? data.message, + isLoading: false, + }); + } else { + // Fail-open + setState(s => ({ ...s, disabled: false, isLoading: false })); + } + } catch { + // Fail-open on network error + setState(s => ({ ...s, disabled: false, isLoading: false })); + } + }, [sdk]); + + useEffect(() => { + check(); + const id = setInterval(check, pollInterval); + return () => clearInterval(id); + }, [check, pollInterval]); + + const value: KillSwitchContextType = { ...state, check }; + return React.createElement(KillSwitchContext.Provider, { value }, children); +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts new file mode 100644 index 0000000..3d2b7f0 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts @@ -0,0 +1,7 @@ +export { + useSurveys, + SurveyProvider, + SurveyModal, + type ActiveSurvey, + type Question, +} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts new file mode 100644 index 0000000..f62654b --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts @@ -0,0 +1,111 @@ +/** + * Surveys module — React context + hook for in-app surveys in React Native apps. + */ + +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import type { PlatformSDK } from '../core.js'; + +export interface Question { + id: string; + text: string; + type: 'rating' | 'text' | 'choice'; + options?: string[]; + required: boolean; +} + +export interface ActiveSurvey { + id: string; + title: string; + description?: string; + questions: Question[]; + expiresAt?: string; +} + +interface SurveyContextType { + activeSurvey: ActiveSurvey | null; + submit: (surveyId: string, answers: Record) => Promise; + dismiss: (surveyId: string) => void; + refresh: () => Promise; +} + +const SurveyContext = createContext(null); + +export function useSurveys(): SurveyContextType { + const ctx = useContext(SurveyContext); + if (!ctx) throw new Error('useSurveys must be used within a SurveyProvider'); + return ctx; +} + +interface SurveyProviderProps { + sdk: PlatformSDK; + /** Poll interval in ms (default: 600000 = 10 min) */ + pollInterval?: number; + children: React.ReactNode; +} + +export function SurveyProvider({ + sdk, + pollInterval = 600_000, + children, +}: SurveyProviderProps): React.JSX.Element { + const [activeSurvey, setActiveSurvey] = useState(null); + + const refresh = useCallback(async () => { + try { + const res = await sdk.fetch('/surveys/active'); + if (!res.ok) return; + const data = (await res.json()) as { survey?: ActiveSurvey | null }; + setActiveSurvey(data.survey ?? null); + } catch { + /* silent */ + } + }, [sdk]); + + const submit = useCallback( + async (surveyId: string, answers: Record) => { + await sdk.fetch(`/surveys/${surveyId}/start`, { method: 'POST' }); + for (const [questionId, answer] of Object.entries(answers)) { + await sdk.fetch(`/surveys/${surveyId}/response`, { + method: 'POST', + body: JSON.stringify({ questionId, answer }), + }); + } + await sdk.fetch(`/surveys/${surveyId}/complete`, { method: 'POST', body: '{}' }); + setActiveSurvey(null); + }, + [sdk] + ); + + const dismiss = useCallback( + (surveyId: string) => { + setActiveSurvey(null); + sdk.fetch(`/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {}); + }, + [sdk] + ); + + useEffect(() => { + refresh(); + const id = setInterval(refresh, pollInterval); + return () => clearInterval(id); + }, [refresh, pollInterval]); + + const value: SurveyContextType = { activeSurvey, submit, dismiss, refresh }; + return React.createElement(SurveyContext.Provider, { value }, children); +} + +// MARK: - UI Components + +interface SurveyModalProps { + survey: ActiveSurvey | null; + onSubmit: (answers: Record) => void; + onDismiss: () => void; +} + +/** + * Placeholder survey modal — product apps should implement their own + * styled version. Returns null. + */ +export function SurveyModal(_props: SurveyModalProps): React.JSX.Element | null { + return null; +} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts new file mode 100644 index 0000000..82a6420 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts @@ -0,0 +1,6 @@ +export { + useTelemetry, + TelemetryProvider, + type TelemetryEvent, + type TelemetryConfig, +} from './telemetry/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts new file mode 100644 index 0000000..3cced99 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts @@ -0,0 +1,134 @@ +/** + * Telemetry module — React context + hook for event tracking in React Native apps. + * Maps queued events to platform-service TelemetryEventSchema for POST /telemetry/events. + */ + +import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react'; +import { Platform } from 'react-native'; +import type { PlatformSDK } from '../core.js'; + +export interface TelemetryEvent { + name: string; + properties?: Record; + timestamp?: string; +} + +export interface TelemetryConfig { + /** Flush interval in ms (default: 30000) */ + flushInterval?: number; + /** Max batch size before auto-flush (default: 20) */ + maxBatchSize?: number; + appVersion?: string; + buildNumber?: string; + /** Default: beta */ + releaseChannel?: 'dev' | 'beta' | 'prod'; + /** Stable anonymous id (e.g. from MMKV). If omitted, generated per app session. */ + getInstallId?: () => string; +} + +function randomUuid(): 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); + }); +} + +interface TelemetryContextType { + track: (name: string, properties?: Record) => void; + flush: () => Promise; +} + +const TelemetryContext = createContext(null); + +export function useTelemetry(): TelemetryContextType { + const ctx = useContext(TelemetryContext); + if (!ctx) throw new Error('useTelemetry must be used within a TelemetryProvider'); + return ctx; +} + +interface TelemetryProviderProps { + sdk: PlatformSDK; + config?: TelemetryConfig; + children: React.ReactNode; +} + +export function TelemetryProvider({ + sdk, + config, + children, +}: TelemetryProviderProps): React.JSX.Element { + const queue = useRef([]); + const sessionIdRef = useRef(randomUuid()); + const installIdRef = useRef(null); + const flushInterval = config?.flushInterval ?? 30_000; + const maxBatchSize = config?.maxBatchSize ?? 20; + + const resolveInstallId = useCallback((): string => { + if (!installIdRef.current) { + installIdRef.current = config?.getInstallId?.() ?? randomUuid(); + } + return installIdRef.current; + }, [config?.getInstallId]); + + const flush = useCallback(async () => { + if (queue.current.length === 0) return; + const batch = queue.current.splice(0); + const os = Platform.OS; + const platform = os === 'ios' ? 'ios' : os === 'android' ? 'android' : 'web'; + const osFamily = platform === 'ios' ? 'ios' : platform === 'android' ? 'android' : 'other'; + const events = batch.map(e => ({ + id: randomUuid(), + productId: sdk.config.productId, + anonymousInstallId: resolveInstallId(), + sessionId: sessionIdRef.current, + platform, + channel: 'mobile_app' as const, + osFamily, + osVersion: String(Platform.Version ?? ''), + appVersion: config?.appVersion ?? '0.0.0', + buildNumber: config?.buildNumber ?? '0', + releaseChannel: config?.releaseChannel ?? ('beta' as const), + eventType: 'info' as const, + module: 'app', + eventName: e.name, + occurredAt: e.timestamp ?? new Date().toISOString(), + context: e.properties, + })); + try { + await sdk.fetch('/telemetry/events', { + method: 'POST', + body: JSON.stringify({ productId: sdk.config.productId, events }), + }); + } catch { + queue.current.unshift(...batch); + } + }, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]); + + const track = useCallback( + (name: string, properties?: Record) => { + queue.current.push({ + name, + properties, + timestamp: new Date().toISOString(), + }); + if (queue.current.length >= maxBatchSize) { + flush(); + } + }, + [maxBatchSize, flush] + ); + + useEffect(() => { + const id = setInterval(flush, flushInterval); + return () => { + clearInterval(id); + flush(); + }; + }, [flush, flushInterval]); + + const value: TelemetryContextType = { track, flush }; + return React.createElement(TelemetryContext.Provider, { value }, children); +} diff --git a/vendor/bytelyst/react-native-platform-sdk/tsconfig.json b/vendor/bytelyst/react-native-platform-sdk/tsconfig.json new file mode 100644 index 0000000..055a6d0 --- /dev/null +++ b/vendor/bytelyst/react-native-platform-sdk/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "jsx": "react-jsx", + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/vendor/bytelyst/telemetry-client/package.json b/vendor/bytelyst/telemetry-client/package.json index ae6b284..0ee6c1d 100644 --- a/vendor/bytelyst/telemetry-client/package.json +++ b/vendor/bytelyst/telemetry-client/package.json @@ -1,6 +1,6 @@ { "name": "@bytelyst/telemetry-client", - "version": "0.1.0", + "version": "0.1.5", "type": "module", "description": "Browser/React Native-safe telemetry client for platform-service", "exports": { diff --git a/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts b/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts new file mode 100644 index 0000000..33cc1d8 --- /dev/null +++ b/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createTelemetryClient } from '../client.js'; +import type { TelemetryStorage } from '../types.js'; + +function createMockStorage(): TelemetryStorage & { store: Map } { + const store = new Map(); + return { + store, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + }; +} + +describe('@bytelyst/telemetry-client', () => { + let storage: ReturnType; + + beforeEach(() => { + storage = createMockStorage(); + vi.restoreAllMocks(); + vi.useFakeTimers(); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('createTelemetryClient', () => { + it('creates a client with all expected methods', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + expect(client.init).toBeTypeOf('function'); + expect(client.trackEvent).toBeTypeOf('function'); + expect(client.flush).toBeTypeOf('function'); + expect(client.shutdown).toBeTypeOf('function'); + expect(client.getInstallId).toBeTypeOf('function'); + expect(client.getSessionId).toBeTypeOf('function'); + }); + }); + + describe('install ID', () => { + it('generates and persists install ID', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + const id = client.getInstallId(); + expect(id).toBeTruthy(); + expect(storage.store.get('testapp_telemetry_install_id')).toBe(id); + }); + + it('reuses persisted install ID', () => { + storage.store.set('testapp_telemetry_install_id', 'existing-id'); + + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + expect(client.getInstallId()).toBe('existing-id'); + }); + }); + + describe('init', () => { + it('generates a session ID on init', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + expect(client.getSessionId()).toBe(''); + client.init(); + expect(client.getSessionId()).toBeTruthy(); + }); + + it('tracks session_started event on init', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + client.init(); + // Flush to see the queued event + client.flush(); + + expect(globalThis.fetch).toHaveBeenCalled(); + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.productId).toBe('testapp'); + expect(body.events).toHaveLength(1); + expect(body.events[0].eventName).toBe('session_started'); + expect(body.events[0].platform).toBe('web'); + expect(body.events[0].channel).toBe('pwa'); + }); + }); + + describe('trackEvent', () => { + it('queues events and flushes via fetch', () => { + const client = createTelemetryClient({ + productId: 'chronomind', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + transport: 'fetch', + storage, + }); + + client.trackEvent('info', 'timer', 'timer_created', { + feature: 'countdown', + tags: { type: 'alarm' }, + metrics: { duration: 300 }, + }); + + client.flush(); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/telemetry/events'); + expect(opts.method).toBe('POST'); + expect(opts.headers['x-product-id']).toBe('chronomind'); + + const body = JSON.parse(opts.body); + expect(body.events).toHaveLength(1); + const ev = body.events[0]; + expect(ev.eventType).toBe('info'); + expect(ev.module).toBe('timer'); + expect(ev.eventName).toBe('timer_created'); + expect(ev.feature).toBe('countdown'); + expect(ev.tags.type).toBe('alarm'); + expect(ev.metrics.duration).toBe(300); + expect(ev.occurredAt).toBeTruthy(); + }); + + it('auto-flushes when queue reaches maxQueue', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'mobile', + channel: 'react_native', + transport: 'fetch', + maxQueue: 3, + storage, + }); + + // Don't init to avoid session_started + client.trackEvent('info', 'a', 'one'); + client.trackEvent('info', 'b', 'two'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + client.trackEvent('info', 'c', 'three'); + expect(globalThis.fetch).toHaveBeenCalledOnce(); + }); + }); + + describe('flush', () => { + it('does nothing when queue is empty', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + client.flush(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('periodic flush', () => { + it('flushes on interval', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + flushIntervalMs: 10_000, + storage, + }); + + client.init(); + (globalThis.fetch as ReturnType).mockClear(); + + client.trackEvent('info', 'test', 'event1'); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(10_000); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + }); + }); + + describe('shutdown', () => { + it('flushes remaining events and stops timer', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + client.init(); + (globalThis.fetch as ReturnType).mockClear(); + + client.trackEvent('info', 'test', 'event1'); + client.shutdown(); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + + // After shutdown, periodic flush should not fire + (globalThis.fetch as ReturnType).mockClear(); + client.trackEvent('info', 'test', 'event2'); + vi.advanceTimersByTime(60_000); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('userId passthrough', () => { + it('includes userId in event when provided', () => { + const client = createTelemetryClient({ + productId: 'testapp', + baseUrl: 'http://localhost:4003/api', + platform: 'web', + channel: 'pwa', + storage, + }); + + client.trackEvent('info', 'auth', 'login', { userId: 'user-123' }); + client.flush(); + + const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); + expect(body.events[0].userId).toBe('user-123'); + }); + }); +}); diff --git a/vendor/bytelyst/telemetry-client/src/client.ts b/vendor/bytelyst/telemetry-client/src/client.ts new file mode 100644 index 0000000..13f91e2 --- /dev/null +++ b/vendor/bytelyst/telemetry-client/src/client.ts @@ -0,0 +1,236 @@ +/** + * Browser/React Native-safe telemetry client for platform-service. + * + * Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard. + * No Node.js dependencies — uses globalThis.fetch and configurable storage. + * + * @example + * ```ts + * import { createTelemetryClient } from '@bytelyst/telemetry-client'; + * + * const telemetry = createTelemetryClient({ + * productId: 'chronomind', + * baseUrl: 'http://localhost:4003/api', + * platform: 'web', + * channel: 'pwa', + * transport: 'beacon', + * }); + * + * telemetry.init(); + * telemetry.trackEvent('info', 'timer', 'timer_created'); + * ``` + */ + +import type { + TelemetryClient, + TelemetryClientConfig, + TelemetryEvent, + TelemetryStorage, +} from './types.js'; + +// ── UUID helper (browser + RN safe) ────────────────────────────── + +function uuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +// ── Noop storage ───────────────────────────────────────────────── + +const noopStorage: TelemetryStorage = { + getItem: () => null, + setItem: () => {}, +}; + +function getDefaultStorage(): TelemetryStorage { + if ( + typeof globalThis.localStorage !== 'undefined' && + typeof globalThis.localStorage?.getItem === 'function' + ) { + return globalThis.localStorage; + } + return noopStorage; +} + +// ── Factory ────────────────────────────────────────────────────── + +export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient { + const { + productId, + baseUrl, + endpoint = '/telemetry/events', + platform, + channel, + transport = 'fetch', + maxQueue = 50, + flushIntervalMs = 30_000, + appVersion = '0.0.0', + buildNumber = '0', + releaseChannel = 'dev', + osFamily = 'other', + osVersion = '', + } = config; + + const storage = config.storage ?? getDefaultStorage(); + const INSTALL_KEY = `${productId}_telemetry_install_id`; + + let queue: TelemetryEvent[] = []; + let sessionId = ''; + let installId = ''; + let flushTimer: ReturnType | null = null; + + function getInstallId(): string { + if (installId) return installId; + const stored = storage.getItem(INSTALL_KEY); + if (stored) { + installId = stored; + return installId; + } + installId = uuid(); + storage.setItem(INSTALL_KEY, installId); + return installId; + } + + function getSessionId(): string { + return sessionId; + } + + function flushViaBeacon(): void { + if (queue.length === 0) return; + const events = [...queue]; + queue = []; + + const body = JSON.stringify({ productId, events }); + const url = `${baseUrl}${endpoint}`; + + try { + const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body); + if (!sent) { + // Fallback to fetch + globalThis + .fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': uuid(), + }, + body, + keepalive: true, + }) + .catch(() => {}); + } + } catch { + // Silently ignore telemetry failures + } + } + + function flushViaFetch(): void { + if (queue.length === 0) return; + const events = [...queue]; + queue = []; + + const body = JSON.stringify({ productId, events }); + const url = `${baseUrl}${endpoint}`; + + globalThis + .fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': uuid(), + }, + body, + }) + .catch(() => {}); + } + + function flush(): void { + if (transport === 'beacon') { + flushViaBeacon(); + } else { + flushViaFetch(); + } + } + + function trackEvent( + eventType: string, + module: string, + eventName: string, + extra?: { + feature?: string; + message?: string; + tags?: Record; + metrics?: Record; + userId?: string; + } + ): void { + const event: TelemetryEvent = { + id: uuid(), + productId, + anonymousInstallId: getInstallId(), + sessionId, + platform, + channel, + osFamily, + osVersion, + appVersion, + buildNumber, + releaseChannel, + eventType, + module, + eventName, + ...extra, + occurredAt: new Date().toISOString(), + }; + + queue.push(event); + + if (queue.length >= maxQueue) { + flush(); + } + } + + function init(): void { + sessionId = uuid(); + getInstallId(); + + // Auto-flush on visibility change (web only) + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flush(); + } + }); + } + + // Periodic flush + if (flushTimer) clearInterval(flushTimer); + flushTimer = setInterval(flush, flushIntervalMs); + + trackEvent('info', 'app_lifecycle', 'session_started'); + } + + function shutdown(): void { + flush(); + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + } + + return { + init, + trackEvent, + flush, + shutdown, + getInstallId, + getSessionId, + }; +} diff --git a/vendor/bytelyst/telemetry-client/src/index.ts b/vendor/bytelyst/telemetry-client/src/index.ts new file mode 100644 index 0000000..ee02c77 --- /dev/null +++ b/vendor/bytelyst/telemetry-client/src/index.ts @@ -0,0 +1,8 @@ +export { createTelemetryClient } from './client.js'; +export { createWebTelemetry, type WebTelemetryConfig } from './web.js'; +export type { + TelemetryClient, + TelemetryClientConfig, + TelemetryEvent, + TelemetryStorage, +} from './types.js'; diff --git a/vendor/bytelyst/telemetry-client/src/types.ts b/vendor/bytelyst/telemetry-client/src/types.ts new file mode 100644 index 0000000..519b99c --- /dev/null +++ b/vendor/bytelyst/telemetry-client/src/types.ts @@ -0,0 +1,107 @@ +/** + * Types for @bytelyst/telemetry-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface TelemetryClientConfig { + /** Product identifier (e.g. 'chronomind', 'nomgap', 'lysnrai'). */ + productId: string; + + /** Platform-service base URL or telemetry ingest endpoint base. */ + baseUrl: string; + + /** Endpoint path appended to baseUrl. Default: '/telemetry/events'. */ + endpoint?: string; + + /** Platform identifier (e.g. 'web', 'mobile', 'desktop'). */ + platform: string; + + /** Channel identifier (e.g. 'pwa', 'react_native', 'web_app'). */ + channel: string; + + /** Transport: 'beacon' uses sendBeacon (web), 'fetch' uses fetch (RN/fallback). Default: 'fetch'. */ + transport?: 'beacon' | 'fetch'; + + /** Max events to queue before auto-flush. Default: 50. */ + maxQueue?: number; + + /** Flush interval in milliseconds. Default: 30000. */ + flushIntervalMs?: number; + + /** App version string. Default: '0.0.0'. */ + appVersion?: string; + + /** Build number. Default: '0'. */ + buildNumber?: string; + + /** Release channel. Default: 'dev'. */ + releaseChannel?: string; + + /** OS family. Default: 'other'. */ + osFamily?: string; + + /** OS version. Default: ''. */ + osVersion?: string; + + /** Storage adapter for install ID persistence. Uses localStorage by default. */ + storage?: TelemetryStorage; +} + +export interface TelemetryStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface TelemetryEvent { + id: string; + productId: string; + userId?: string; + anonymousInstallId: string; + sessionId: string; + platform: string; + channel: string; + osFamily: string; + osVersion: string; + appVersion: string; + buildNumber: string; + releaseChannel: string; + eventType: string; + module: string; + eventName: string; + feature?: string; + message?: string; + tags?: Record; + metrics?: Record; + occurredAt: string; +} + +export interface TelemetryClient { + /** Initialize the telemetry client and start periodic flushing. */ + init(): void; + + /** Track a telemetry event. */ + trackEvent( + eventType: string, + module: string, + eventName: string, + extra?: { + feature?: string; + message?: string; + tags?: Record; + metrics?: Record; + userId?: string; + } + ): void; + + /** Flush all queued events immediately. */ + flush(): void; + + /** Stop the periodic flush timer and flush remaining events. */ + shutdown(): void; + + /** Get the anonymous install ID. */ + getInstallId(): string; + + /** Get the current session ID. */ + getSessionId(): string; +} diff --git a/vendor/bytelyst/telemetry-client/src/web.ts b/vendor/bytelyst/telemetry-client/src/web.ts new file mode 100644 index 0000000..47468d9 --- /dev/null +++ b/vendor/bytelyst/telemetry-client/src/web.ts @@ -0,0 +1,81 @@ +/** + * Convenience factory for web dashboard telemetry. + * + * Eliminates ~30 lines of boilerplate per web app by wrapping + * createTelemetryClient() with sensible web defaults. + * + * @example + * ```ts + * import { createWebTelemetry } from '@bytelyst/telemetry-client'; + * + * const { client, init, trackPageView } = createWebTelemetry({ + * productId: 'nomgap', + * channel: 'nomgap_web', + * }); + * export { client as telemetryClient, init as initTelemetry, trackPageView }; + * ``` + */ + +import { createTelemetryClient } from './client.js'; +import type { TelemetryClient } from './types.js'; + +export interface WebTelemetryConfig { + /** Product identifier (e.g. 'nomgap', 'chronomind'). */ + productId: string; + /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ + channel: string; + /** Platform-service base URL. Default: 'http://localhost:4003/api'. */ + baseUrl?: string; + /** Telemetry ingest endpoint path. Default: '/telemetry/events'. */ + endpoint?: string; + /** Transport: 'beacon' or 'fetch'. Default: 'fetch'. */ + transport?: 'beacon' | 'fetch'; + /** App version string. Default: '0.1.0'. */ + appVersion?: string; + /** Build number. Default: '1'. */ + buildNumber?: string; + /** Release channel. Default: 'dev'. */ + releaseChannel?: string; + /** OS family. Default: 'other'. */ + osFamily?: string; +} + +export interface WebTelemetry { + /** The underlying telemetry client instance. */ + client: TelemetryClient; + /** Initialize telemetry and track app_initialized event. Idempotent. */ + init(): TelemetryClient; + /** Track a page view event. */ + trackPageView(page: string): void; +} + +export function createWebTelemetry(config: WebTelemetryConfig): WebTelemetry { + let initialized = false; + + const client = createTelemetryClient({ + productId: config.productId, + baseUrl: config.baseUrl ?? 'http://localhost:4003/api', + endpoint: config.endpoint ?? '/telemetry/events', + platform: 'web', + channel: config.channel, + transport: config.transport ?? 'fetch', + appVersion: config.appVersion ?? '0.1.0', + buildNumber: config.buildNumber ?? '1', + releaseChannel: config.releaseChannel ?? 'dev', + osFamily: config.osFamily ?? 'other', + }); + + function init(): TelemetryClient { + if (initialized) return client; + client.init(); + client.trackEvent('info', 'app_shell', 'web_app_initialized'); + initialized = true; + return client; + } + + function trackPageView(page: string): void { + client.trackEvent('info', 'navigation', 'page_view', { feature: page }); + } + + return { client, init, trackPageView }; +} diff --git a/vendor/bytelyst/telemetry-client/tsconfig.json b/vendor/bytelyst/telemetry-client/tsconfig.json new file mode 100644 index 0000000..318c075 --- /dev/null +++ b/vendor/bytelyst/telemetry-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/web/Dockerfile b/web/Dockerfile index 3047ccc..51c0f3d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -11,9 +11,10 @@ ARG BYTELYST_PACKAGE_SOURCE=vendor ENV GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN} ENV BYTELYST_PACKAGE_SOURCE=${BYTELYST_PACKAGE_SOURCE} -COPY .npmrc pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY .npmrc .pnpmfile.cjs pnpm-workspace.yaml pnpm-lock.yaml* ./ COPY package.json ./package.json COPY web/package.json ./web/package.json +COPY vendor/ ./vendor/ RUN pnpm install --filter @bytelyst/trading-web