fix(docker): vendor platform packages for container builds

This commit is contained in:
root 2026-05-05 20:31:48 +00:00
parent 1bd0297066
commit 2a510ded83
100 changed files with 8133 additions and 82 deletions

View File

@ -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

View File

@ -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"
},

130
pnpm-lock.yaml generated
View File

@ -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': {}

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/api-client",
"version": "0.1.0",
"version": "0.1.5",
"type": "module",
"exports": {
".": {

View File

@ -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<string, string>;
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<string, string>;
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');
});
});
});

151
vendor/bytelyst/api-client/src/client.ts vendored Normal file
View File

@ -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<void> {
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<User[]>("/users");
*
* // Never throws
* const { data, error } = await api.safeFetch<User[]>("/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<string, string> = {
'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<string, string> = {};
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<Response> {
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<T>(path: string, options?: RequestInit): Promise<T> {
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<T>;
},
async safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
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' };
}
},
};
}

View File

@ -0,0 +1,2 @@
export { createApiClient } from './client.js';
export type { ApiClient, ApiClientConfig, ApiResult } from './types.js';

28
vendor/bytelyst/api-client/src/types.ts vendored Normal file
View File

@ -0,0 +1,28 @@
export interface ApiClientConfig {
baseUrl: string;
getToken?: () => string | null;
defaultHeaders?: Record<string, string>;
/** 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<T> {
data: T | null;
error: string | null;
}
export interface ApiClient {
/**
* Fetch that throws on error use when caller handles errors.
*/
fetch<T>(path: string, options?: RequestInit): Promise<T>;
/**
* Safe fetch that never throws returns { data, error } tuple.
*/
safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>>;
}

View File

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

View File

@ -1,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/"
}
}
}

View File

@ -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
});
});

View File

@ -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
});
});

View File

@ -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,
});
});
});

View File

@ -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');
});
});

5
vendor/bytelyst/auth/src/index.ts vendored Normal file
View File

@ -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';

176
vendor/bytelyst/auth/src/jwt.ts vendored Normal file
View File

@ -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<typeof createRemoteJWKSet> | null = null;
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
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<JoseCryptoKey> {
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<typeof createRemoteJWKSet> {
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<string, unknown>, expiry: string): Promise<string> {
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<TokenPayload | null> {
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<TokenPayload | null> {
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;
}
},
};
}

54
vendor/bytelyst/auth/src/middleware.ts vendored Normal file
View File

@ -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<AuthPayload> {
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<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw new ForbiddenError('Insufficient permissions');
}
return payload;
}

15
vendor/bytelyst/auth/src/password.ts vendored Normal file
View File

@ -0,0 +1,15 @@
/**
* Password hashing utilities using bcryptjs.
*/
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, SALT_ROUNDS);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

26
vendor/bytelyst/auth/src/server-auth.ts vendored Normal file
View File

@ -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<TUser>(
authHeader: string | null,
verifyToken: (token: string) => Promise<TokenPayload | null>,
getUserById: (id: string) => Promise<TUser | null>
): Promise<TUser | null> {
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);
}

41
vendor/bytelyst/auth/src/types.ts vendored Normal file
View File

@ -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<string>;
createRefreshToken(payload: { sub: string; productId?: string }): Promise<string>;
verifyToken(token: string): Promise<TokenPayload | null>;
}

9
vendor/bytelyst/auth/tsconfig.json vendored Normal file
View File

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

8
vendor/bytelyst/auth/vitest.config.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
pool: 'forks',
testTimeout: 15_000,
},
});

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/config",
"version": "0.1.0",
"version": "0.1.5",
"type": "module",
"exports": {
".": {

View File

@ -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;
});
});

View File

@ -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_]*$/);
}
});
});

View File

@ -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<string, unknown>).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();
});
});

View File

@ -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<typeof baseEnvSchema>;

38
vendor/bytelyst/config/src/index.ts vendored Normal file
View File

@ -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';

136
vendor/bytelyst/config/src/keyvault.ts vendored Normal file
View File

@ -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<void> {
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<void> {
return resolveAzureKeyVaultSecrets(secrets, opts);
}
/**
* Azure Key Vault implementation.
*/
async function resolveAzureKeyVaultSecrets(
secrets: SecretMapping[],
opts?: { vaultUrl?: string }
): Promise<void> {
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<string, SecretMapping>;

26
vendor/bytelyst/config/src/loader.ts vendored Normal file
View File

@ -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<T extends ZodRawShape>(extension?: T) {
const schema = extension ? baseEnvSchema.extend(extension) : baseEnvSchema;
return schema.parse(process.env) as z.infer<typeof baseEnvSchema> &
(T extends ZodRawShape ? z.infer<z.ZodObject<T>> : Record<string, never>);
}

View File

@ -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;
}

View File

@ -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<string>();
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<typeof ProductManifestSchema>;
export type Platform = z.infer<typeof PlatformSchema>;
export type Theme = z.infer<typeof ThemeSchema>;
export type ContainerDef = z.infer<typeof ContainerDefSchema>;
export type FeatureFlag = z.infer<typeof FeatureFlagSchema>;
export type PortConfig = z.infer<typeof PortConfigSchema>;
/**
* 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<ProductManifest> {
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;
}

9
vendor/bytelyst/config/tsconfig.json vendored Normal file
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/cosmos",
"version": "0.1.0",
"version": "0.1.5",
"type": "module",
"exports": {
".": {

View File

@ -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'");
});
});

54
vendor/bytelyst/cosmos/src/client.ts vendored Normal file
View File

@ -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;
}

125
vendor/bytelyst/cosmos/src/containers.ts vendored Normal file
View File

@ -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<string, ContainerConfig> = new Map();
const _containerCache: Map<string, Container> = new Map();
/**
* Register containers with their partition key configuration.
* Call once at app startup before any getRegisteredContainer() calls.
*/
export function registerContainers(definitions: Record<string, ContainerConfig>): 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<void> {
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<void> {
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<typeof getCosmosClient>,
dbId: string
): Promise<Database> {
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<void> {
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();
}

8
vendor/bytelyst/cosmos/src/index.ts vendored Normal file
View File

@ -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';

4
vendor/bytelyst/cosmos/src/types.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface ContainerConfig {
partitionKeyPath: string;
defaultTtl?: number | null;
}

9
vendor/bytelyst/cosmos/tsconfig.json vendored Normal file
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/errors",
"version": "0.1.0",
"version": "0.1.6",
"type": "module",
"exports": {
".": {

View File

@ -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 });
});
});

View File

@ -0,0 +1,37 @@
import { ServiceError } from './service-error.js';
export class BadRequestError extends ServiceError {
constructor(message = 'Bad request', details?: Record<string, unknown>) {
super(400, message, details);
}
}
export class UnauthorizedError extends ServiceError {
constructor(message = 'Unauthorized', details?: Record<string, unknown>) {
super(401, message, details);
}
}
export class ForbiddenError extends ServiceError {
constructor(message = 'Forbidden', details?: Record<string, unknown>) {
super(403, message, details);
}
}
export class NotFoundError extends ServiceError {
constructor(message = 'Not found', details?: Record<string, unknown>) {
super(404, message, details);
}
}
export class ConflictError extends ServiceError {
constructor(message = 'Conflict', details?: Record<string, unknown>) {
super(409, message, details);
}
}
export class TooManyRequestsError extends ServiceError {
constructor(message = 'Too many requests', details?: Record<string, unknown>) {
super(429, message, details);
}
}

9
vendor/bytelyst/errors/src/index.ts vendored Normal file
View File

@ -0,0 +1,9 @@
export { ServiceError } from './service-error.js';
export {
BadRequestError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
TooManyRequestsError,
} from './http-errors.js';

View File

@ -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<string, unknown>
) {
super(message);
this.name = 'ServiceError';
}
}

9
vendor/bytelyst/errors/tsconfig.json vendored Normal file
View File

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

View File

@ -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": {

View File

@ -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');
});
});

View File

@ -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<KillSwitchResult>;
}
export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient {
const { baseUrl, productId, platform = 'mobile' } = config;
async function check(): Promise<KillSwitchResult> {
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 };
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/llm",
"version": "0.1.0",
"version": "0.1.5",
"type": "module",
"exports": {
".": {

View File

@ -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<ChatCompletionResponse> => {
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<ChatCompletionResponse> => {
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<ChatCompletionResponse> => {
throw new Error('a failed');
},
};
const b = {
isConfigured: () => true,
chatCompletion: async (): Promise<ChatCompletionResponse> => {
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');
});
});

View File

@ -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');
});
});

View File

@ -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<string, string>)['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'
);
});
});

105
vendor/bytelyst/llm/src/factory.ts vendored Normal file
View File

@ -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;
}

36
vendor/bytelyst/llm/src/fallback.ts vendored Normal file
View File

@ -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<ChatCompletionResponse> {
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'
);
},
};
}

24
vendor/bytelyst/llm/src/index.ts vendored Normal file
View File

@ -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';

View File

@ -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<AzureOpenAIConfig>) {
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<string, string> {
return {
'Content-Type': 'application/json',
'api-key': this.config.apiKey,
};
}
async chatCompletion(req: ChatCompletionRequest): Promise<ChatCompletionResponse> {
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<string> {
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<EmbeddingResponse> {
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,
},
};
}
}

View File

@ -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<ChatCompletionResponse> {
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')}`);
}
}

View File

@ -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<GeminiConfig>) {
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<ChatCompletionResponse> {
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<string, unknown> = { 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 };
}
}

View File

@ -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<ChatCompletionResponse> {
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<string> {
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<EmbeddingResponse> {
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 = [];
}
}

View File

@ -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<OpenAIConfig>) {
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<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
};
}
async chatCompletion(req: ChatCompletionRequest): Promise<ChatCompletionResponse> {
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<string> {
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<EmbeddingResponse> {
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,
},
};
}
}

View File

@ -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<PerplexityConfig>) {
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<ChatCompletionResponse> {
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,
},
};
}
}

18
vendor/bytelyst/llm/src/testing.ts vendored Normal file
View File

@ -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';

131
vendor/bytelyst/llm/src/types.ts vendored Normal file
View File

@ -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<ChatCompletionResponse>;
/** Stream a chat completion response — yields content delta strings. */
chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable<string>;
/** Generate vector embeddings for input text(s). */
embed?(req: EmbeddingRequest): Promise<EmbeddingResponse>;
/** 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');
}

9
vendor/bytelyst/llm/tsconfig.json vendored Normal file
View File

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

View File

@ -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/"
}
}
}

View File

@ -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<string, string> = {};
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<Parameters<typeof createAuthProvider<TestUser>>[0]>) {
return createAuthProvider<TestUser>({
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(
<AuthProvider>
<div data-testid="child">Hello</div>
</AuthProvider>
);
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 (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="loading">{String(isLoading)}</span>
<span data-testid="user">{user ? user.email : 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
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 (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
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<boolean>;
function LoginComponent() {
const { login, user, isAuthenticated } = useAuth();
loginFn = login;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<LoginComponent />
</AuthProvider>
);
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<boolean>;
function LoginComponent() {
const { login, isAuthenticated, error } = useAuth();
loginFn = login;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="error">{error ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<LoginComponent />
</AuthProvider>
);
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 <span data-testid="auth">{String(isAuthenticated)}</span>;
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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(<Bad />)).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<boolean>;
function Component() {
const { login, user } = useAuth();
loginFn = login;
return <span data-testid="email">{user?.email ?? 'none'}</span>;
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
let updateUserFn: (updates: Partial<TestUser>) => void;
function Component() {
const { login, updateUser, user } = useAuth();
loginFn = login;
updateUserFn = updateUser;
return (
<div>
<span data-testid="name">{user?.name ?? 'none'}</span>
<span data-testid="role">{user?.role ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<TestUser>) => void;
function Component() {
const { updateUser, user } = useAuth();
updateUserFn = updateUser;
return <span data-testid="user">{user ? 'yes' : 'no'}</span>;
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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 (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
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 <span data-testid="email">{user?.email ?? 'none'}</span>;
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
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 <span data-testid="email">{user?.email ?? 'none'}</span>;
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
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<TestUser>({
storagePrefix: 'custom',
loginEndpoint: '/login',
mapLoginResponse: (d: unknown) =>
d as { user: TestUser; accessToken: string; refreshToken: string },
});
function Display() {
const { user } = useAuth();
return <span data-testid="email">{user?.email ?? 'none'}</span>;
}
render(
<AuthProvider>
<Display />
</AuthProvider>
);
expect(screen.getByTestId('email').textContent).toBe('x@y.com');
});
});

View File

@ -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<string, string> = {};
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<Parameters<typeof createAuthProvider<TestUser>>[0]>) {
return createAuthProvider<TestUser>({
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<boolean>;
function Component() {
const { loginWithGoogle, user, isAuthenticated } = useAuth();
loginWithGoogleFn = loginWithGoogle;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
function Component() {
const { loginWithGoogle, isAuthenticated, error } = useAuth();
loginWithGoogleFn = loginWithGoogle;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="error">{error ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
function Component() {
const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth();
loginFn = login;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
<span data-testid="challenge">{mfaChallenge ?? 'none'}</span>
<span data-testid="methods">{mfaMethods.join(',') || 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise<boolean>;
function Component() {
const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth();
loginFn = login;
verifyMfaFn = verifyMfa;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
<span data-testid="email">{user?.email ?? 'none'}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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 (
<div>
<span data-testid="count">{providers.length}</span>
<span data-testid="hasLink">{String(typeof linkProvider === 'function')}</span>
<span data-testid="hasUnlink">{String(typeof unlinkProvider === 'function')}</span>
<span data-testid="hasRefresh">{String(typeof refreshProviders === 'function')}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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<boolean>;
let logoutFn: () => void;
function Component() {
const { login, logout, isAuthenticated, mfaRequired } = useAuth();
loginFn = login;
logoutFn = logout;
return (
<div>
<span data-testid="auth">{String(isAuthenticated)}</span>
<span data-testid="mfa">{String(mfaRequired)}</span>
</div>
);
}
render(
<AuthProvider>
<Component />
</AuthProvider>
);
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');
});
});

View File

@ -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<AdminUser>({
* storagePrefix: "admin",
* loginEndpoint: "/auth/login",
* registerEndpoint: "/auth/register",
* forgotPasswordEndpoint: "/auth/forgot-password",
* changePasswordEndpoint: "/auth/change-password",
* deleteAccountEndpoint: "/auth/delete-account",
* refreshEndpoint: "/auth/refresh",
* mapLoginResponse: (data) => ({
* user: data.user,
* accessToken: data.accessToken,
* refreshToken: data.refreshToken,
* }),
* });
* ```
*/
export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: AuthConfig<TUser>) {
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<AuthContextValue<TUser> | 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<TUser | null>(() => {
// 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<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [providers, setProviders] = useState<AuthProviderInfo[]>([]);
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaMethods, setMfaMethods] = useState<string[]>([]);
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | 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<string, unknown>): 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<unknown>(loginEndpoint, {
method: 'POST',
body: JSON.stringify(
configProductId
? { email, password, productId: configProductId }
: { email, password }
),
});
if (data && !fetchError) {
if (handleMfaChallenge(data as Record<string, unknown>)) {
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<unknown>(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<string, string> = { idToken };
if (configProductId) oauthBody.productId = configProductId;
const { data, error: fetchError } = await api.safeFetch<unknown>(
`${oauthEndpoint}/${provider}`,
{ method: 'POST', body: JSON.stringify(oauthBody) }
);
if (data && !fetchError) {
if (handleMfaChallenge(data as Record<string, unknown>)) {
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<void>(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<void>(
`${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<unknown>(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<TUser>) => {
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 (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
error,
success,
login,
register,
logout,
forgotPassword,
changePassword,
deleteAccount,
updateUser,
clearMessages,
// SmartAuth: Social login (Phase 1C)
loginWithGoogle,
loginWithMicrosoft,
loginWithApple,
// SmartAuth: Provider management (Phase 1C)
providers,
linkProvider,
unlinkProvider,
refreshProviders,
// SmartAuth: MFA state (Phase 2D)
mfaRequired,
mfaMethods,
mfaChallenge,
verifyMfa,
}}
>
{children}
</AuthContext.Provider>
);
}
function useAuth(): AuthContextValue<TUser> {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
return { AuthProvider, useAuth };
}

View File

@ -0,0 +1,8 @@
export { createAuthProvider } from './auth-context.js';
export type {
AuthProviderInfo,
BaseUser,
AuthContextValue,
AuthConfig,
LoginResult,
} from './types.js';

89
vendor/bytelyst/react-auth/src/types.ts vendored Normal file
View File

@ -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<TUser extends BaseUser = BaseUser> {
user: TUser | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
success: string | null;
login: (email: string, password: string) => Promise<boolean>;
register: (email: string, password: string, displayName: string) => Promise<boolean>;
logout: () => void;
forgotPassword: (email: string) => Promise<boolean>;
changePassword: (currentPassword: string, newPassword: string) => Promise<boolean>;
deleteAccount: (password: string) => Promise<boolean>;
updateUser: (updates: Partial<TUser>) => void;
clearMessages: () => void;
// ── SmartAuth: Social login (Phase 1C) ────────────
loginWithGoogle: (idToken: string) => Promise<boolean>;
loginWithMicrosoft: (idToken: string) => Promise<boolean>;
loginWithApple: (idToken: string) => Promise<boolean>;
// ── SmartAuth: Provider management (Phase 1C) ─────
providers: AuthProviderInfo[];
linkProvider: (provider: string, idToken: string) => Promise<boolean>;
unlinkProvider: (provider: string) => Promise<boolean>;
refreshProviders: () => Promise<void>;
// ── SmartAuth: MFA state (Phase 2D) ───────────────
mfaRequired: boolean;
mfaMethods: string[];
mfaChallenge: string | null;
verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise<boolean>;
}
export interface LoginResult<TUser extends BaseUser = BaseUser> {
user: TUser;
accessToken: string;
refreshToken: string;
}
export interface AuthConfig<TUser extends BaseUser = BaseUser> {
/** 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<TUser>;
onLoginFallback?: (
email: string,
password: string,
error: string
) => Promise<LoginResult<TUser> | null>;
/** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */
onInit?: () => LoginResult<TUser> | 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;
}

View File

@ -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"]
}

View File

@ -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',
},
});

View File

@ -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/"
}
}

View File

@ -0,0 +1 @@
export { useAuth, AuthProvider, type AuthState, type AuthContextType } from './auth/index.js';

View File

@ -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<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
loginWithGoogle: (idToken: string) => Promise<void>;
loginWithApple: (idToken: string) => Promise<void>;
logout: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(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<AuthState>({
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);
}

View File

@ -0,0 +1,7 @@
export {
useBroadcasts,
BroadcastProvider,
InAppMessageBanner,
BroadcastModal,
type InAppMessage,
} from './broadcasts/index.js';

View File

@ -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<void>;
}
const BroadcastContext = createContext<BroadcastContextType | null>(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<InAppMessage[]>([]);
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;
}

View File

@ -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<Response>;
}
/**
* 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<Response> => {
const url = `${config.baseURL}${path}`;
const token = config.getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-product-id': config.productId,
...((init?.headers as Record<string, string>) ?? {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return globalThis.fetch(url, { ...init, headers });
};
return { config, fetch: platformFetch };
}

View File

@ -0,0 +1 @@
export { useFeatureFlags, FeatureFlagProvider, type FeatureFlag } from './feature-flags/index.js';

View File

@ -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<string, FeatureFlag>;
isEnabled: (key: string) => boolean;
getValue: <T = unknown>(key: string, fallback: T) => T;
refresh: () => Promise<void>;
}
const FeatureFlagContext = createContext<FeatureFlagContextType | null>(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<Map<string, FeatureFlag>>(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<string, boolean> };
const map = new Map<string, FeatureFlag>();
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(
<T = unknown>(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);
}

View File

@ -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';

View File

@ -0,0 +1 @@
export { useKillSwitch, KillSwitchProvider, type KillSwitchState } from './kill-switch/index.js';

View File

@ -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<void>;
}
const KillSwitchContext = createContext<KillSwitchContextType | null>(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<KillSwitchState>({
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);
}

View File

@ -0,0 +1,7 @@
export {
useSurveys,
SurveyProvider,
SurveyModal,
type ActiveSurvey,
type Question,
} from './surveys/index.js';

View File

@ -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<string, unknown>) => Promise<void>;
dismiss: (surveyId: string) => void;
refresh: () => Promise<void>;
}
const SurveyContext = createContext<SurveyContextType | null>(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<ActiveSurvey | null>(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<string, unknown>) => {
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<string, unknown>) => 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;
}

View File

@ -0,0 +1,6 @@
export {
useTelemetry,
TelemetryProvider,
type TelemetryEvent,
type TelemetryConfig,
} from './telemetry/index.js';

View File

@ -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<string, unknown>;
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<string, unknown>) => void;
flush: () => Promise<void>;
}
const TelemetryContext = createContext<TelemetryContextType | null>(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<TelemetryEvent[]>([]);
const sessionIdRef = useRef(randomUuid());
const installIdRef = useRef<string | null>(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<string, unknown>) => {
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);
}

View File

@ -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"]
}

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 };
}

View File

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

View File

@ -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