fix(docker): vendor platform packages for container builds
This commit is contained in:
parent
1bd0297066
commit
2a510ded83
@ -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
|
||||
|
||||
|
||||
@ -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
130
pnpm-lock.yaml
generated
@ -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': {}
|
||||
|
||||
|
||||
2
vendor/bytelyst/api-client/package.json
vendored
2
vendor/bytelyst/api-client/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/api-client",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
133
vendor/bytelyst/api-client/src/__tests__/api-client.test.ts
vendored
Normal file
133
vendor/bytelyst/api-client/src/__tests__/api-client.test.ts
vendored
Normal 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
151
vendor/bytelyst/api-client/src/client.ts
vendored
Normal 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' };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
2
vendor/bytelyst/api-client/src/index.ts
vendored
Normal file
2
vendor/bytelyst/api-client/src/index.ts
vendored
Normal 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
28
vendor/bytelyst/api-client/src/types.ts
vendored
Normal 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>>;
|
||||
}
|
||||
10
vendor/bytelyst/api-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/api-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
2
vendor/bytelyst/auth/package.json
vendored
2
vendor/bytelyst/auth/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/auth",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
137
vendor/bytelyst/auth/src/__tests__/auth.test.ts
vendored
Normal file
137
vendor/bytelyst/auth/src/__tests__/auth.test.ts
vendored
Normal 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
|
||||
});
|
||||
});
|
||||
101
vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts
vendored
Normal file
101
vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts
vendored
Normal 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
|
||||
});
|
||||
});
|
||||
117
vendor/bytelyst/auth/src/__tests__/middleware.test.ts
vendored
Normal file
117
vendor/bytelyst/auth/src/__tests__/middleware.test.ts
vendored
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
133
vendor/bytelyst/auth/src/__tests__/rs256.test.ts
vendored
Normal file
133
vendor/bytelyst/auth/src/__tests__/rs256.test.ts
vendored
Normal 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
5
vendor/bytelyst/auth/src/index.ts
vendored
Normal 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
176
vendor/bytelyst/auth/src/jwt.ts
vendored
Normal 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
54
vendor/bytelyst/auth/src/middleware.ts
vendored
Normal 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
15
vendor/bytelyst/auth/src/password.ts
vendored
Normal 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
26
vendor/bytelyst/auth/src/server-auth.ts
vendored
Normal 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
41
vendor/bytelyst/auth/src/types.ts
vendored
Normal 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
9
vendor/bytelyst/auth/tsconfig.json
vendored
Normal 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
8
vendor/bytelyst/auth/vitest.config.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
pool: 'forks',
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
2
vendor/bytelyst/config/package.json
vendored
2
vendor/bytelyst/config/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/config",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
167
vendor/bytelyst/config/src/__tests__/config.test.ts
vendored
Normal file
167
vendor/bytelyst/config/src/__tests__/config.test.ts
vendored
Normal 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;
|
||||
});
|
||||
});
|
||||
180
vendor/bytelyst/config/src/__tests__/keyvault.test.ts
vendored
Normal file
180
vendor/bytelyst/config/src/__tests__/keyvault.test.ts
vendored
Normal 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_]*$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
467
vendor/bytelyst/config/src/__tests__/product-manifest.test.ts
vendored
Normal file
467
vendor/bytelyst/config/src/__tests__/product-manifest.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
19
vendor/bytelyst/config/src/base-schema.ts
vendored
Normal file
19
vendor/bytelyst/config/src/base-schema.ts
vendored
Normal 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
38
vendor/bytelyst/config/src/index.ts
vendored
Normal 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
136
vendor/bytelyst/config/src/keyvault.ts
vendored
Normal 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
26
vendor/bytelyst/config/src/loader.ts
vendored
Normal 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>);
|
||||
}
|
||||
75
vendor/bytelyst/config/src/product-identity.ts
vendored
Normal file
75
vendor/bytelyst/config/src/product-identity.ts
vendored
Normal 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;
|
||||
}
|
||||
305
vendor/bytelyst/config/src/product-manifest.ts
vendored
Normal file
305
vendor/bytelyst/config/src/product-manifest.ts
vendored
Normal 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
9
vendor/bytelyst/config/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
2
vendor/bytelyst/cosmos/package.json
vendored
2
vendor/bytelyst/cosmos/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/cosmos",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
152
vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts
vendored
Normal file
152
vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts
vendored
Normal 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
54
vendor/bytelyst/cosmos/src/client.ts
vendored
Normal 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
125
vendor/bytelyst/cosmos/src/containers.ts
vendored
Normal 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
8
vendor/bytelyst/cosmos/src/index.ts
vendored
Normal 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
4
vendor/bytelyst/cosmos/src/types.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface ContainerConfig {
|
||||
partitionKeyPath: string;
|
||||
defaultTtl?: number | null;
|
||||
}
|
||||
9
vendor/bytelyst/cosmos/tsconfig.json
vendored
Normal file
9
vendor/bytelyst/cosmos/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
2
vendor/bytelyst/errors/package.json
vendored
2
vendor/bytelyst/errors/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/errors",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
56
vendor/bytelyst/errors/src/__tests__/errors.test.ts
vendored
Normal file
56
vendor/bytelyst/errors/src/__tests__/errors.test.ts
vendored
Normal 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 });
|
||||
});
|
||||
});
|
||||
37
vendor/bytelyst/errors/src/http-errors.ts
vendored
Normal file
37
vendor/bytelyst/errors/src/http-errors.ts
vendored
Normal 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
9
vendor/bytelyst/errors/src/index.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
export { ServiceError } from './service-error.js';
|
||||
export {
|
||||
BadRequestError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
TooManyRequestsError,
|
||||
} from './http-errors.js';
|
||||
14
vendor/bytelyst/errors/src/service-error.ts
vendored
Normal file
14
vendor/bytelyst/errors/src/service-error.ts
vendored
Normal 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
9
vendor/bytelyst/errors/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
102
vendor/bytelyst/kill-switch-client/src/index.test.ts
vendored
Normal file
102
vendor/bytelyst/kill-switch-client/src/index.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
73
vendor/bytelyst/kill-switch-client/src/index.ts
vendored
Normal file
73
vendor/bytelyst/kill-switch-client/src/index.ts
vendored
Normal 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 };
|
||||
}
|
||||
10
vendor/bytelyst/kill-switch-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/kill-switch-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
2
vendor/bytelyst/llm/package.json
vendored
2
vendor/bytelyst/llm/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/llm",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
99
vendor/bytelyst/llm/src/__tests__/fallback.test.ts
vendored
Normal file
99
vendor/bytelyst/llm/src/__tests__/fallback.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
299
vendor/bytelyst/llm/src/__tests__/llm.test.ts
vendored
Normal file
299
vendor/bytelyst/llm/src/__tests__/llm.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
181
vendor/bytelyst/llm/src/__tests__/providers.test.ts
vendored
Normal file
181
vendor/bytelyst/llm/src/__tests__/providers.test.ts
vendored
Normal 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
105
vendor/bytelyst/llm/src/factory.ts
vendored
Normal 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
36
vendor/bytelyst/llm/src/fallback.ts
vendored
Normal 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
24
vendor/bytelyst/llm/src/index.ts
vendored
Normal 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';
|
||||
226
vendor/bytelyst/llm/src/providers/azure-openai.ts
vendored
Normal file
226
vendor/bytelyst/llm/src/providers/azure-openai.ts
vendored
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
47
vendor/bytelyst/llm/src/providers/fallback.ts
vendored
Normal file
47
vendor/bytelyst/llm/src/providers/fallback.ts
vendored
Normal 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')}`);
|
||||
}
|
||||
}
|
||||
122
vendor/bytelyst/llm/src/providers/gemini.ts
vendored
Normal file
122
vendor/bytelyst/llm/src/providers/gemini.ts
vendored
Normal 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 };
|
||||
}
|
||||
}
|
||||
118
vendor/bytelyst/llm/src/providers/mock.ts
vendored
Normal file
118
vendor/bytelyst/llm/src/providers/mock.ts
vendored
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
205
vendor/bytelyst/llm/src/providers/openai.ts
vendored
Normal file
205
vendor/bytelyst/llm/src/providers/openai.ts
vendored
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
74
vendor/bytelyst/llm/src/providers/perplexity.ts
vendored
Normal file
74
vendor/bytelyst/llm/src/providers/perplexity.ts
vendored
Normal 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
18
vendor/bytelyst/llm/src/testing.ts
vendored
Normal 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
131
vendor/bytelyst/llm/src/types.ts
vendored
Normal 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
9
vendor/bytelyst/llm/tsconfig.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
2
vendor/bytelyst/react-auth/package.json
vendored
2
vendor/bytelyst/react-auth/package.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/react-auth",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
479
vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx
vendored
Normal file
479
vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
339
vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx
vendored
Normal file
339
vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
551
vendor/bytelyst/react-auth/src/auth-context.tsx
vendored
Normal file
551
vendor/bytelyst/react-auth/src/auth-context.tsx
vendored
Normal 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 };
|
||||
}
|
||||
8
vendor/bytelyst/react-auth/src/index.ts
vendored
Normal file
8
vendor/bytelyst/react-auth/src/index.ts
vendored
Normal 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
89
vendor/bytelyst/react-auth/src/types.ts
vendored
Normal 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;
|
||||
}
|
||||
11
vendor/bytelyst/react-auth/tsconfig.json
vendored
Normal file
11
vendor/bytelyst/react-auth/tsconfig.json
vendored
Normal 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"]
|
||||
}
|
||||
10
vendor/bytelyst/react-auth/vitest.config.ts
vendored
Normal file
10
vendor/bytelyst/react-auth/vitest.config.ts
vendored
Normal 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',
|
||||
},
|
||||
});
|
||||
BIN
vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13
vendored
Normal file
BIN
vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13
vendored
Normal file
Binary file not shown.
BIN
vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c
vendored
Normal file
BIN
vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c
vendored
Normal file
Binary file not shown.
66
vendor/bytelyst/react-native-platform-sdk/package.json
vendored
Normal file
66
vendor/bytelyst/react-native-platform-sdk/package.json
vendored
Normal 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/"
|
||||
}
|
||||
}
|
||||
1
vendor/bytelyst/react-native-platform-sdk/src/auth.ts
vendored
Normal file
1
vendor/bytelyst/react-native-platform-sdk/src/auth.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export { useAuth, AuthProvider, type AuthState, type AuthContextType } from './auth/index.js';
|
||||
208
vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts
vendored
Normal file
208
vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts
vendored
Normal 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);
|
||||
}
|
||||
7
vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts
vendored
Normal file
7
vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useBroadcasts,
|
||||
BroadcastProvider,
|
||||
InAppMessageBanner,
|
||||
BroadcastModal,
|
||||
type InAppMessage,
|
||||
} from './broadcasts/index.js';
|
||||
125
vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts
vendored
Normal file
125
vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts
vendored
Normal 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;
|
||||
}
|
||||
40
vendor/bytelyst/react-native-platform-sdk/src/core.ts
vendored
Normal file
40
vendor/bytelyst/react-native-platform-sdk/src/core.ts
vendored
Normal 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 };
|
||||
}
|
||||
1
vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts
vendored
Normal file
1
vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export { useFeatureFlags, FeatureFlagProvider, type FeatureFlag } from './feature-flags/index.js';
|
||||
89
vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts
vendored
Normal file
89
vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts
vendored
Normal 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);
|
||||
}
|
||||
55
vendor/bytelyst/react-native-platform-sdk/src/index.ts
vendored
Normal file
55
vendor/bytelyst/react-native-platform-sdk/src/index.ts
vendored
Normal 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';
|
||||
1
vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts
vendored
Normal file
1
vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export { useKillSwitch, KillSwitchProvider, type KillSwitchState } from './kill-switch/index.js';
|
||||
76
vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts
vendored
Normal file
76
vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts
vendored
Normal 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);
|
||||
}
|
||||
7
vendor/bytelyst/react-native-platform-sdk/src/surveys.ts
vendored
Normal file
7
vendor/bytelyst/react-native-platform-sdk/src/surveys.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
useSurveys,
|
||||
SurveyProvider,
|
||||
SurveyModal,
|
||||
type ActiveSurvey,
|
||||
type Question,
|
||||
} from './surveys/index.js';
|
||||
111
vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts
vendored
Normal file
111
vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts
vendored
Normal 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;
|
||||
}
|
||||
6
vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts
vendored
Normal file
6
vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
export {
|
||||
useTelemetry,
|
||||
TelemetryProvider,
|
||||
type TelemetryEvent,
|
||||
type TelemetryConfig,
|
||||
} from './telemetry/index.js';
|
||||
134
vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts
vendored
Normal file
134
vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts
vendored
Normal 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);
|
||||
}
|
||||
12
vendor/bytelyst/react-native-platform-sdk/tsconfig.json
vendored
Normal file
12
vendor/bytelyst/react-native-platform-sdk/tsconfig.json
vendored
Normal 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"]
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
255
vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts
vendored
Normal file
255
vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts
vendored
Normal file
@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createTelemetryClient } from '../client.js';
|
||||
import type { TelemetryStorage } from '../types.js';
|
||||
|
||||
function createMockStorage(): TelemetryStorage & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe('@bytelyst/telemetry-client', () => {
|
||||
let storage: ReturnType<typeof createMockStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createTelemetryClient', () => {
|
||||
it('creates a client with all expected methods', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
expect(client.init).toBeTypeOf('function');
|
||||
expect(client.trackEvent).toBeTypeOf('function');
|
||||
expect(client.flush).toBeTypeOf('function');
|
||||
expect(client.shutdown).toBeTypeOf('function');
|
||||
expect(client.getInstallId).toBeTypeOf('function');
|
||||
expect(client.getSessionId).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install ID', () => {
|
||||
it('generates and persists install ID', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
const id = client.getInstallId();
|
||||
expect(id).toBeTruthy();
|
||||
expect(storage.store.get('testapp_telemetry_install_id')).toBe(id);
|
||||
});
|
||||
|
||||
it('reuses persisted install ID', () => {
|
||||
storage.store.set('testapp_telemetry_install_id', 'existing-id');
|
||||
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
expect(client.getInstallId()).toBe('existing-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('generates a session ID on init', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
expect(client.getSessionId()).toBe('');
|
||||
client.init();
|
||||
expect(client.getSessionId()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tracks session_started event on init', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
client.init();
|
||||
// Flush to see the queued event
|
||||
client.flush();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||
expect(body.productId).toBe('testapp');
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0].eventName).toBe('session_started');
|
||||
expect(body.events[0].platform).toBe('web');
|
||||
expect(body.events[0].channel).toBe('pwa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('queues events and flushes via fetch', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'chronomind',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
transport: 'fetch',
|
||||
storage,
|
||||
});
|
||||
|
||||
client.trackEvent('info', 'timer', 'timer_created', {
|
||||
feature: 'countdown',
|
||||
tags: { type: 'alarm' },
|
||||
metrics: { duration: 300 },
|
||||
});
|
||||
|
||||
client.flush();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('http://localhost:4003/api/telemetry/events');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers['x-product-id']).toBe('chronomind');
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.events).toHaveLength(1);
|
||||
const ev = body.events[0];
|
||||
expect(ev.eventType).toBe('info');
|
||||
expect(ev.module).toBe('timer');
|
||||
expect(ev.eventName).toBe('timer_created');
|
||||
expect(ev.feature).toBe('countdown');
|
||||
expect(ev.tags.type).toBe('alarm');
|
||||
expect(ev.metrics.duration).toBe(300);
|
||||
expect(ev.occurredAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('auto-flushes when queue reaches maxQueue', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'mobile',
|
||||
channel: 'react_native',
|
||||
transport: 'fetch',
|
||||
maxQueue: 3,
|
||||
storage,
|
||||
});
|
||||
|
||||
// Don't init to avoid session_started
|
||||
client.trackEvent('info', 'a', 'one');
|
||||
client.trackEvent('info', 'b', 'two');
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
|
||||
client.trackEvent('info', 'c', 'three');
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('does nothing when queue is empty', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
client.flush();
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('periodic flush', () => {
|
||||
it('flushes on interval', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
flushIntervalMs: 10_000,
|
||||
storage,
|
||||
});
|
||||
|
||||
client.init();
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
client.trackEvent('info', 'test', 'event1');
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('flushes remaining events and stops timer', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
client.init();
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
client.trackEvent('info', 'test', 'event1');
|
||||
client.shutdown();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
||||
|
||||
// After shutdown, periodic flush should not fire
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
client.trackEvent('info', 'test', 'event2');
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('userId passthrough', () => {
|
||||
it('includes userId in event when provided', () => {
|
||||
const client = createTelemetryClient({
|
||||
productId: 'testapp',
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
platform: 'web',
|
||||
channel: 'pwa',
|
||||
storage,
|
||||
});
|
||||
|
||||
client.trackEvent('info', 'auth', 'login', { userId: 'user-123' });
|
||||
client.flush();
|
||||
|
||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
||||
expect(body.events[0].userId).toBe('user-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
236
vendor/bytelyst/telemetry-client/src/client.ts
vendored
Normal file
236
vendor/bytelyst/telemetry-client/src/client.ts
vendored
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Browser/React Native-safe telemetry client for platform-service.
|
||||
*
|
||||
* Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard.
|
||||
* No Node.js dependencies — uses globalThis.fetch and configurable storage.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createTelemetryClient } from '@bytelyst/telemetry-client';
|
||||
*
|
||||
* const telemetry = createTelemetryClient({
|
||||
* productId: 'chronomind',
|
||||
* baseUrl: 'http://localhost:4003/api',
|
||||
* platform: 'web',
|
||||
* channel: 'pwa',
|
||||
* transport: 'beacon',
|
||||
* });
|
||||
*
|
||||
* telemetry.init();
|
||||
* telemetry.trackEvent('info', 'timer', 'timer_created');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
TelemetryClient,
|
||||
TelemetryClientConfig,
|
||||
TelemetryEvent,
|
||||
TelemetryStorage,
|
||||
} from './types.js';
|
||||
|
||||
// ── UUID helper (browser + RN safe) ──────────────────────────────
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Noop storage ─────────────────────────────────────────────────
|
||||
|
||||
const noopStorage: TelemetryStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
};
|
||||
|
||||
function getDefaultStorage(): TelemetryStorage {
|
||||
if (
|
||||
typeof globalThis.localStorage !== 'undefined' &&
|
||||
typeof globalThis.localStorage?.getItem === 'function'
|
||||
) {
|
||||
return globalThis.localStorage;
|
||||
}
|
||||
return noopStorage;
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────
|
||||
|
||||
export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient {
|
||||
const {
|
||||
productId,
|
||||
baseUrl,
|
||||
endpoint = '/telemetry/events',
|
||||
platform,
|
||||
channel,
|
||||
transport = 'fetch',
|
||||
maxQueue = 50,
|
||||
flushIntervalMs = 30_000,
|
||||
appVersion = '0.0.0',
|
||||
buildNumber = '0',
|
||||
releaseChannel = 'dev',
|
||||
osFamily = 'other',
|
||||
osVersion = '',
|
||||
} = config;
|
||||
|
||||
const storage = config.storage ?? getDefaultStorage();
|
||||
const INSTALL_KEY = `${productId}_telemetry_install_id`;
|
||||
|
||||
let queue: TelemetryEvent[] = [];
|
||||
let sessionId = '';
|
||||
let installId = '';
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function getInstallId(): string {
|
||||
if (installId) return installId;
|
||||
const stored = storage.getItem(INSTALL_KEY);
|
||||
if (stored) {
|
||||
installId = stored;
|
||||
return installId;
|
||||
}
|
||||
installId = uuid();
|
||||
storage.setItem(INSTALL_KEY, installId);
|
||||
return installId;
|
||||
}
|
||||
|
||||
function getSessionId(): string {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function flushViaBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const events = [...queue];
|
||||
queue = [];
|
||||
|
||||
const body = JSON.stringify({ productId, events });
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body);
|
||||
if (!sent) {
|
||||
// Fallback to fetch
|
||||
globalThis
|
||||
.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-product-id': productId,
|
||||
'x-request-id': uuid(),
|
||||
},
|
||||
body,
|
||||
keepalive: true,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore telemetry failures
|
||||
}
|
||||
}
|
||||
|
||||
function flushViaFetch(): void {
|
||||
if (queue.length === 0) return;
|
||||
const events = [...queue];
|
||||
queue = [];
|
||||
|
||||
const body = JSON.stringify({ productId, events });
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
globalThis
|
||||
.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-product-id': productId,
|
||||
'x-request-id': uuid(),
|
||||
},
|
||||
body,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (transport === 'beacon') {
|
||||
flushViaBeacon();
|
||||
} else {
|
||||
flushViaFetch();
|
||||
}
|
||||
}
|
||||
|
||||
function trackEvent(
|
||||
eventType: string,
|
||||
module: string,
|
||||
eventName: string,
|
||||
extra?: {
|
||||
feature?: string;
|
||||
message?: string;
|
||||
tags?: Record<string, string>;
|
||||
metrics?: Record<string, number>;
|
||||
userId?: string;
|
||||
}
|
||||
): void {
|
||||
const event: TelemetryEvent = {
|
||||
id: uuid(),
|
||||
productId,
|
||||
anonymousInstallId: getInstallId(),
|
||||
sessionId,
|
||||
platform,
|
||||
channel,
|
||||
osFamily,
|
||||
osVersion,
|
||||
appVersion,
|
||||
buildNumber,
|
||||
releaseChannel,
|
||||
eventType,
|
||||
module,
|
||||
eventName,
|
||||
...extra,
|
||||
occurredAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
queue.push(event);
|
||||
|
||||
if (queue.length >= maxQueue) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
sessionId = uuid();
|
||||
getInstallId();
|
||||
|
||||
// Auto-flush on visibility change (web only)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic flush
|
||||
if (flushTimer) clearInterval(flushTimer);
|
||||
flushTimer = setInterval(flush, flushIntervalMs);
|
||||
|
||||
trackEvent('info', 'app_lifecycle', 'session_started');
|
||||
}
|
||||
|
||||
function shutdown(): void {
|
||||
flush();
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
trackEvent,
|
||||
flush,
|
||||
shutdown,
|
||||
getInstallId,
|
||||
getSessionId,
|
||||
};
|
||||
}
|
||||
8
vendor/bytelyst/telemetry-client/src/index.ts
vendored
Normal file
8
vendor/bytelyst/telemetry-client/src/index.ts
vendored
Normal 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';
|
||||
107
vendor/bytelyst/telemetry-client/src/types.ts
vendored
Normal file
107
vendor/bytelyst/telemetry-client/src/types.ts
vendored
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Types for @bytelyst/telemetry-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface TelemetryClientConfig {
|
||||
/** Product identifier (e.g. 'chronomind', 'nomgap', 'lysnrai'). */
|
||||
productId: string;
|
||||
|
||||
/** Platform-service base URL or telemetry ingest endpoint base. */
|
||||
baseUrl: string;
|
||||
|
||||
/** Endpoint path appended to baseUrl. Default: '/telemetry/events'. */
|
||||
endpoint?: string;
|
||||
|
||||
/** Platform identifier (e.g. 'web', 'mobile', 'desktop'). */
|
||||
platform: string;
|
||||
|
||||
/** Channel identifier (e.g. 'pwa', 'react_native', 'web_app'). */
|
||||
channel: string;
|
||||
|
||||
/** Transport: 'beacon' uses sendBeacon (web), 'fetch' uses fetch (RN/fallback). Default: 'fetch'. */
|
||||
transport?: 'beacon' | 'fetch';
|
||||
|
||||
/** Max events to queue before auto-flush. Default: 50. */
|
||||
maxQueue?: number;
|
||||
|
||||
/** Flush interval in milliseconds. Default: 30000. */
|
||||
flushIntervalMs?: number;
|
||||
|
||||
/** App version string. Default: '0.0.0'. */
|
||||
appVersion?: string;
|
||||
|
||||
/** Build number. Default: '0'. */
|
||||
buildNumber?: string;
|
||||
|
||||
/** Release channel. Default: 'dev'. */
|
||||
releaseChannel?: string;
|
||||
|
||||
/** OS family. Default: 'other'. */
|
||||
osFamily?: string;
|
||||
|
||||
/** OS version. Default: ''. */
|
||||
osVersion?: string;
|
||||
|
||||
/** Storage adapter for install ID persistence. Uses localStorage by default. */
|
||||
storage?: TelemetryStorage;
|
||||
}
|
||||
|
||||
export interface TelemetryStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId?: string;
|
||||
anonymousInstallId: string;
|
||||
sessionId: string;
|
||||
platform: string;
|
||||
channel: string;
|
||||
osFamily: string;
|
||||
osVersion: string;
|
||||
appVersion: string;
|
||||
buildNumber: string;
|
||||
releaseChannel: string;
|
||||
eventType: string;
|
||||
module: string;
|
||||
eventName: string;
|
||||
feature?: string;
|
||||
message?: string;
|
||||
tags?: Record<string, string>;
|
||||
metrics?: Record<string, number>;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface TelemetryClient {
|
||||
/** Initialize the telemetry client and start periodic flushing. */
|
||||
init(): void;
|
||||
|
||||
/** Track a telemetry event. */
|
||||
trackEvent(
|
||||
eventType: string,
|
||||
module: string,
|
||||
eventName: string,
|
||||
extra?: {
|
||||
feature?: string;
|
||||
message?: string;
|
||||
tags?: Record<string, string>;
|
||||
metrics?: Record<string, number>;
|
||||
userId?: string;
|
||||
}
|
||||
): void;
|
||||
|
||||
/** Flush all queued events immediately. */
|
||||
flush(): void;
|
||||
|
||||
/** Stop the periodic flush timer and flush remaining events. */
|
||||
shutdown(): void;
|
||||
|
||||
/** Get the anonymous install ID. */
|
||||
getInstallId(): string;
|
||||
|
||||
/** Get the current session ID. */
|
||||
getSessionId(): string;
|
||||
}
|
||||
81
vendor/bytelyst/telemetry-client/src/web.ts
vendored
Normal file
81
vendor/bytelyst/telemetry-client/src/web.ts
vendored
Normal 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 };
|
||||
}
|
||||
10
vendor/bytelyst/telemetry-client/tsconfig.json
vendored
Normal file
10
vendor/bytelyst/telemetry-client/tsconfig.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user