diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..64d3585 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.git +mobile +ios +android +web/node_modules +web/dist +web/.next \ No newline at end of file diff --git a/.npmrc.docker b/.npmrc.docker index 1bc9e12..6ddafb5 100644 --- a/.npmrc.docker +++ b/.npmrc.docker @@ -1 +1 @@ -@bytelyst:registry=http://gitea.bytelyst.com:3300/api/packages/bytelyst/npm/ \ No newline at end of file +@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 05e9527..7f78838 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,33 +1,48 @@ FROM node:20-alpine AS builder -WORKDIR /app/backend +WORKDIR /app RUN corepack enable && corepack prepare pnpm@10.6.5 --activate # Use Gitea npm registry for @bytelyst/* packages -COPY .npmrc.docker ./.npmrc COPY backend/package.json ./package.json RUN --mount=type=secret,id=gitea_npm_token \ TOKEN=$(cat /run/secrets/gitea_npm_token) && \ - echo "//gitea.bytelyst.com:3300/:_authToken=$TOKEN" >> .npmrc && \ + printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\nlink-workspace-packages=false\nshared-workspace-lockfile=false\n' "$TOKEN" > .npmrc && \ pnpm install --ignore-scripts --lockfile=false -COPY backend/tsconfig.json ./tsconfig.json -COPY backend/src/ ./src/ -COPY shared/ ../shared/ -RUN pnpm run build +COPY backend/tsconfig.json ./backend/tsconfig.json +COPY backend/src/ ./backend/src/ +COPY shared/ ./shared/ +RUN cd /app/backend && ../node_modules/.bin/tsc -p tsconfig.json FROM node:20-alpine WORKDIR /app/backend ENV NODE_ENV=production -COPY --from=builder /app/backend/node_modules ./node_modules -COPY --from=builder /app/backend/package.json ./package.json +# Build metadata baked at image build time (consumed by @bytelyst/devops) +ARG BYTELYST_COMMIT_SHA=unknown +ARG BYTELYST_COMMIT_SHA_FULL=unknown +ARG BYTELYST_BRANCH=unknown +ARG BYTELYST_BUILT_AT=unknown +ARG BYTELYST_COMMIT_AUTHOR=unknown +ARG BYTELYST_COMMIT_MESSAGE=unknown +ARG BYTELYST_DOCKER_IMAGE=invttrdg-backend:latest +ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \ + BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \ + BYTELYST_BRANCH=${BYTELYST_BRANCH} \ + BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \ + BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \ + BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \ + BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE} + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/backend/dist ./dist -COPY shared/ ../shared/ +COPY --from=builder /app/shared/ ../shared/ RUN chown -R node:node /app USER node EXPOSE 4018 -CMD ["node", "dist/backend/src/bootstrap.js"] \ No newline at end of file +CMD ["node", "dist/backend/src/bootstrap.js"] diff --git a/backend/package.json b/backend/package.json index 8669fe5..c8070a3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -75,7 +75,9 @@ "jose": "^6.1.2", "prom-client": "^15.1.3", "socket.io": "^4.8.3", - "winston": "^3.19.0" + "winston": "^3.19.0", + "@bytelyst/telemetry-client": "*", + "@bytelyst/devops": "^0.1.1" }, "devDependencies": { "@types/node": "^25.0.3", diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index c367c03..9dc99c6 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -3,6 +3,7 @@ import { createServer } from 'http'; import { Server, Socket } from 'socket.io'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; +import { collectDevopsInfo } from '@bytelyst/devops/server'; import logger from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; @@ -2364,6 +2365,20 @@ export class ApiServer { res.json(flags); }); + // ── DevOps info: build, runtime, config (auth required) ────────────── + this.app.get('/api/devops/info', this.requireAuth, async (_req, res) => { + try { + const info = await collectDevopsInfo({ + productId: config.PRODUCT_ID || 'invttrdg', + serviceName: 'trading-backend', + serviceVersion: process.env.npm_package_version || '0.1.0', + }); + res.json(info); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + }); + this.app.get('/api/me/profile', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; @@ -2381,6 +2396,11 @@ export class ApiServer { trade_enable: true, }); + // JWT role is authoritative — prevent drift between platform users and trading_users. + if (authReq.authRole) { + profile.role = authReq.authRole; + } + res.json({ profile }); }); @@ -2394,13 +2414,19 @@ export class ApiServer { const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); try { - const profile = await saveCurrentUserProfile(authUserId, req.body || {}, { + // Strip role from client-supplied patch — role comes from JWT only. + const { role: _ignoredRole, ...patchBody } = (req.body || {}) as Record; + const profile = await saveCurrentUserProfile(authUserId, patchBody, { email: authReq.authEmail, role: authReq.authRole, first_name: displayNameParts[0] || '', last_name: displayNameParts.slice(1).join(' '), trade_enable: true, }); + // JWT role is authoritative. + if (authReq.authRole) { + profile.role = authReq.authRole; + } res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile: ${error.message}` }); diff --git a/vendor/bytelyst/accessibility/package.json b/vendor/bytelyst/accessibility/package.json deleted file mode 100644 index 365bed6..0000000 --- a/vendor/bytelyst/accessibility/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@bytelyst/accessibility", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "dependencies": {}, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/accessibility/src/index.ts b/vendor/bytelyst/accessibility/src/index.ts deleted file mode 100644 index 1d760ab..0000000 --- a/vendor/bytelyst/accessibility/src/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -export type AlertA11yProps = { - role: 'alert'; - 'aria-live': 'assertive' | 'polite'; - 'aria-label': string; -}; - -export function alertLabel(level: string, description: string): AlertA11yProps { - return { - role: 'alert', - 'aria-live': level === 'danger' ? 'assertive' : 'polite', - 'aria-label': description, - }; -} - -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); - -export function getFocusableElements(container: HTMLElement): HTMLElement[] { - return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - element => - !element.hasAttribute('disabled') && - element.getAttribute('aria-hidden') !== 'true' && - element.offsetParent !== null - ); -} - -export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void { - if (event.key !== 'Tab') return; - - const focusable = getFocusableElements(container); - if (focusable.length === 0) { - event.preventDefault(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } -} - -export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void { - const preferred = container.querySelector(selector); - const fallback = getFocusableElements(container)[0]; - (preferred ?? fallback)?.focus(); -} - -export type ScreenReaderPoliteness = 'assertive' | 'polite'; - -export function announceToScreenReader( - message: string, - politeness: ScreenReaderPoliteness = 'polite' -): void { - if (typeof document === 'undefined') return; - - const id = `bytelyst-sr-announcer-${politeness}`; - let announcer = document.getElementById(id); - if (!announcer) { - announcer = document.createElement('div'); - announcer.id = id; - announcer.setAttribute('aria-live', politeness); - announcer.setAttribute('aria-atomic', 'true'); - announcer.style.position = 'absolute'; - announcer.style.width = '1px'; - announcer.style.height = '1px'; - announcer.style.margin = '-1px'; - announcer.style.padding = '0'; - announcer.style.overflow = 'hidden'; - announcer.style.clip = 'rect(0 0 0 0)'; - announcer.style.whiteSpace = 'nowrap'; - announcer.style.border = '0'; - document.body.appendChild(announcer); - } - - announcer.textContent = ''; - window.setTimeout(() => { - if (announcer) { - announcer.textContent = message; - } - }, 10); -} - -export type ProgressbarA11yProps = { - role: 'progressbar'; - 'aria-label': string; - 'aria-valuenow': number; - 'aria-valuemin': number; - 'aria-valuemax': number; - 'aria-valuetext': string; -}; - -export function progressLabel( - label: string, - valuePct: number, - description: string -): ProgressbarA11yProps { - return { - role: 'progressbar', - 'aria-label': label, - 'aria-valuenow': valuePct, - 'aria-valuemin': 0, - 'aria-valuemax': 100, - 'aria-valuetext': description, - }; -} - -export type AriaLabelOnly = { 'aria-label': string }; - -export function streakLabel(days: number): AriaLabelOnly { - return { 'aria-label': `${days} day streak` }; -} - -export type ButtonA11yProps = { - 'aria-label': string; - 'aria-roledescription'?: string; -}; - -export function buttonLabel(text: string, hint?: string): ButtonA11yProps { - return { - 'aria-label': text, - ...(hint ? { 'aria-roledescription': hint } : {}), - }; -} - -export function achievementLabel(name: string, description: string): AriaLabelOnly { - return { 'aria-label': `Achievement: ${name} — ${description}` }; -} - -export type TimerA11yProps = { - 'aria-label': string; - 'aria-live': 'polite'; -}; - -export function timerLabel( - hours: number, - minutes: number, - seconds: number, - status: string -): TimerA11yProps { - return { - 'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`, - 'aria-live': 'polite', - }; -} - -export type SliderA11yProps = { - role: 'slider'; - 'aria-label': string; - 'aria-valuenow': number; - 'aria-valuemin': number; - 'aria-valuemax': number; -}; - -export function sliderLabel( - label: string, - value: number, - min: number, - max: number -): SliderA11yProps { - return { - role: 'slider', - 'aria-label': label, - 'aria-valuenow': value, - 'aria-valuemin': min, - 'aria-valuemax': max, - }; -} - -function plural(n: number, singular: string, pluralForm: string): string { - const word = n === 1 ? singular : pluralForm; - return `${n} ${word}`; -} - -/** - * Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes". - */ -export function formatDurationForA11y(hours: number): string { - const totalMinutes = Math.round(hours * 60); - const h = Math.floor(totalMinutes / 60); - const m = totalMinutes % 60; - - if (h === 0 && m === 0) { - return '0 minutes'; - } - if (m === 0) { - return plural(h, 'hour', 'hours'); - } - if (h === 0) { - return plural(m, 'minute', 'minutes'); - } - return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`; -} diff --git a/vendor/bytelyst/accessibility/tsconfig.json b/vendor/bytelyst/accessibility/tsconfig.json deleted file mode 100644 index 5626e4b..0000000 --- a/vendor/bytelyst/accessibility/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022", "DOM"], - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src/**/*.ts"] -} diff --git a/vendor/bytelyst/api-client/package.json b/vendor/bytelyst/api-client/package.json deleted file mode 100644 index 0261f1f..0000000 --- a/vendor/bytelyst/api-client/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@bytelyst/api-client", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts b/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts deleted file mode 100644 index 1e14e65..0000000 --- a/vendor/bytelyst/api-client/src/__tests__/api-client.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { createApiClient } from '../index.js'; - -// Mock globalThis.fetch -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -function jsonResponse(data: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - statusText: status === 200 ? 'OK' : 'Error', - json: () => Promise.resolve(data), - }; -} - -describe('createApiClient', () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it('returns an object with fetch and safeFetch', () => { - const api = createApiClient({ baseUrl: 'http://localhost:4003' }); - expect(typeof api.fetch).toBe('function'); - expect(typeof api.safeFetch).toBe('function'); - }); - - describe('fetch', () => { - it('calls correct URL with base + path', async () => { - mockFetch.mockResolvedValue(jsonResponse({ users: [] })); - const api = createApiClient({ baseUrl: 'http://localhost:4003/api' }); - - await api.fetch('/users'); - - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/users', - expect.objectContaining({ - headers: expect.objectContaining({ 'Content-Type': 'application/json' }), - }) - ); - }); - - it('returns parsed JSON on success', async () => { - mockFetch.mockResolvedValue(jsonResponse({ id: '1', name: 'Test' })); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.fetch<{ id: string; name: string }>('/users/1'); - expect(result).toEqual({ id: '1', name: 'Test' }); - }); - - it('throws on HTTP error', async () => { - mockFetch.mockResolvedValue(jsonResponse({ error: 'Not found' }, 404)); - const api = createApiClient({ baseUrl: '/api' }); - - await expect(api.fetch('/users/999')).rejects.toThrow('Not found'); - }); - - it('injects auth token from getToken', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - getToken: () => 'my-jwt-token', - }); - - await api.fetch('/protected'); - - expect(mockFetch).toHaveBeenCalledWith( - '/api/protected', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer my-jwt-token', - }), - }) - ); - }); - - it('skips auth header when getToken returns null', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - getToken: () => null, - }); - - await api.fetch('/public'); - - const headers = mockFetch.mock.calls[0][1].headers as Record; - expect(headers.Authorization).toBeUndefined(); - }); - - it('merges defaultHeaders', async () => { - mockFetch.mockResolvedValue(jsonResponse({ ok: true })); - const api = createApiClient({ - baseUrl: '/api', - defaultHeaders: { 'X-Custom': 'value' }, - }); - - await api.fetch('/test'); - - const headers = mockFetch.mock.calls[0][1].headers as Record; - expect(headers['X-Custom']).toBe('value'); - expect(headers['Content-Type']).toBe('application/json'); - }); - }); - - describe('safeFetch', () => { - it('returns { data, error: null } on success', async () => { - mockFetch.mockResolvedValue(jsonResponse({ id: '1' })); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch<{ id: string }>('/items/1'); - expect(result.data).toEqual({ id: '1' }); - expect(result.error).toBeNull(); - }); - - it('returns { data: null, error } on HTTP error', async () => { - mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403)); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch('/secret'); - expect(result.data).toBeNull(); - expect(result.error).toBe('Forbidden'); - }); - - it('returns { data: null, error } on network error', async () => { - mockFetch.mockRejectedValue(new Error('Network error')); - const api = createApiClient({ baseUrl: '/api' }); - - const result = await api.safeFetch('/unreachable'); - expect(result.data).toBeNull(); - expect(result.error).toBe('API unavailable'); - }); - }); -}); diff --git a/vendor/bytelyst/api-client/src/client.ts b/vendor/bytelyst/api-client/src/client.ts deleted file mode 100644 index 8230966..0000000 --- a/vendor/bytelyst/api-client/src/client.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Configurable API client factory. - * Creates a fetch wrapper with base URL, auth token injection, error handling, - * timeout, and retry with exponential backoff for idempotent requests. - */ - -import type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; - -const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -/** - * Create an API client with a base URL and optional auth token. - * - * @example - * ```ts - * const api = createApiClient({ - * baseUrl: "/api", - * getToken: () => localStorage.getItem("access_token"), - * }); - * - * // Throws on error - * const users = await api.fetch("/users"); - * - * // Never throws - * const { data, error } = await api.safeFetch("/users"); - * ``` - */ -export function createApiClient(config: ApiClientConfig): ApiClient { - const { - baseUrl, - getToken, - defaultHeaders, - timeoutMs = 10_000, - retries = 2, - retryDelayMs = 500, - } = config; - - function buildHeaders(options?: RequestInit): HeadersInit { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-request-id': - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - ...defaultHeaders, - }; - - if (getToken) { - const token = getToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - - if (options?.headers) { - const extra: Record = {}; - if (options.headers instanceof Headers) { - options.headers.forEach((value, key) => { - extra[key] = value; - }); - } else if (Array.isArray(options.headers)) { - Object.assign(extra, Object.fromEntries(options.headers)); - } else { - Object.assign(extra, options.headers); - } - Object.assign(headers, extra); - } - - return headers; - } - - function buildInit(options?: RequestInit): RequestInit { - const init: RequestInit = { - ...options, - headers: buildHeaders(options), - }; - - // AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0) - if (timeoutMs > 0 && !options?.signal) { - const controller = new AbortController(); - init.signal = controller.signal; - setTimeout(() => controller.abort(), timeoutMs); - } - - return init; - } - - function isRetryable(method: string | undefined): boolean { - return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()); - } - - async function fetchWithRetry(url: string, init: RequestInit): Promise { - const maxAttempts = isRetryable(init.method) ? retries + 1 : 1; - let lastError: unknown; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const res = await globalThis.fetch(url, init); - // Only retry on 502/503/504 for idempotent methods - if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) { - await sleep(retryDelayMs * 2 ** attempt); - continue; - } - return res; - } catch (err) { - lastError = err; - if (attempt < maxAttempts - 1) { - await sleep(retryDelayMs * 2 ** attempt); - continue; - } - } - } - - throw lastError; - } - - return { - async fetch(path: string, options?: RequestInit): Promise { - const init = buildInit(options); - const res = await fetchWithRetry(`${baseUrl}${path}`, init); - - if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(body.error || `HTTP ${res.status}`); - } - - return res.json() as Promise; - }, - - async safeFetch(path: string, options?: RequestInit): Promise> { - try { - const init = buildInit(options); - const res = await fetchWithRetry(`${baseUrl}${path}`, init); - - if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })); - return { data: null, error: body.error || `HTTP ${res.status}` }; - } - - const data = (await res.json()) as T; - return { data, error: null }; - } catch { - return { data: null, error: 'API unavailable' }; - } - }, - }; -} diff --git a/vendor/bytelyst/api-client/src/index.ts b/vendor/bytelyst/api-client/src/index.ts deleted file mode 100644 index 4805981..0000000 --- a/vendor/bytelyst/api-client/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createApiClient } from './client.js'; -export type { ApiClient, ApiClientConfig, ApiResult } from './types.js'; diff --git a/vendor/bytelyst/api-client/src/types.ts b/vendor/bytelyst/api-client/src/types.ts deleted file mode 100644 index a4252a6..0000000 --- a/vendor/bytelyst/api-client/src/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface ApiClientConfig { - baseUrl: string; - getToken?: () => string | null; - defaultHeaders?: Record; - /** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */ - timeoutMs?: number; - /** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */ - retries?: number; - /** Base delay in ms for exponential backoff between retries. Default: 500. */ - retryDelayMs?: number; -} - -export interface ApiResult { - data: T | null; - error: string | null; -} - -export interface ApiClient { - /** - * Fetch that throws on error — use when caller handles errors. - */ - fetch(path: string, options?: RequestInit): Promise; - - /** - * Safe fetch that never throws — returns { data, error } tuple. - */ - safeFetch(path: string, options?: RequestInit): Promise>; -} diff --git a/vendor/bytelyst/api-client/tsconfig.json b/vendor/bytelyst/api-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/api-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth-client/package.json b/vendor/bytelyst/auth-client/package.json deleted file mode 100644 index bc36103..0000000 --- a/vendor/bytelyst/auth-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/auth-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe auth API client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts b/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts deleted file mode 100644 index ac5292a..0000000 --- a/vendor/bytelyst/auth-client/src/__tests__/auth-client.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createAuthClient } from '../client.js'; -import type { TokenStorage } from '../types.js'; - -function createMockStorage(): TokenStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - }; -} - -function mockFetchResponse(data: unknown, status = 200) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - }); -} - -describe('@bytelyst/auth-client', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - }); - - describe('createAuthClient', () => { - it('creates a client with all expected methods', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - expect(client.login).toBeTypeOf('function'); - expect(client.register).toBeTypeOf('function'); - expect(client.getMe).toBeTypeOf('function'); - expect(client.refreshAccessToken).toBeTypeOf('function'); - expect(client.forgotPassword).toBeTypeOf('function'); - expect(client.resetPassword).toBeTypeOf('function'); - expect(client.changePassword).toBeTypeOf('function'); - expect(client.deleteAccount).toBeTypeOf('function'); - expect(client.verifyEmail).toBeTypeOf('function'); - expect(client.resendVerification).toBeTypeOf('function'); - expect(client.getAccessToken).toBeTypeOf('function'); - expect(client.getRefreshToken).toBeTypeOf('function'); - expect(client.setTokens).toBeTypeOf('function'); - expect(client.clearTokens).toBeTypeOf('function'); - expect(client.isAuthenticated).toBeTypeOf('function'); - }); - }); - - describe('token management', () => { - it('stores and retrieves tokens', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - expect(client.isAuthenticated()).toBe(false); - expect(client.getAccessToken()).toBeNull(); - - client.setTokens('access-123', 'refresh-456'); - - expect(client.isAuthenticated()).toBe(true); - expect(client.getAccessToken()).toBe('access-123'); - expect(client.getRefreshToken()).toBe('refresh-456'); - }); - - it('clears tokens', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - client.setTokens('access-123', 'refresh-456'); - client.clearTokens(); - - expect(client.isAuthenticated()).toBe(false); - expect(client.getAccessToken()).toBeNull(); - expect(client.getRefreshToken()).toBeNull(); - }); - - it('uses productId as storage key prefix by default', () => { - createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storage, - }).setTokens('a', 'b'); - - expect(storage.store.get('chronomind-auth-token')).toBe('a'); - expect(storage.store.get('chronomind-refresh-token')).toBe('b'); - }); - - it('respects custom storagePrefix', () => { - createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storagePrefix: 'cm', - storage, - }).setTokens('a', 'b'); - - expect(storage.store.get('cm-auth-token')).toBe('a'); - }); - }); - - describe('login', () => { - it('sends correct request and stores tokens', async () => { - const mockData = { - accessToken: 'at-123', - refreshToken: 'rt-456', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.login('a@b.com', 'pass123'); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('at-123'); - expect(client.getRefreshToken()).toBe('rt-456'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/login'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.email).toBe('a@b.com'); - expect(body.productId).toBe('testapp'); - - expect(opts.headers['x-product-id']).toBe('testapp'); - expect(opts.headers['x-request-id']).toBeTruthy(); - }); - - it('throws on login failure', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Invalid credentials' }, 401); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await expect(client.login('a@b.com', 'wrong')).rejects.toThrow('Invalid credentials'); - expect(client.isAuthenticated()).toBe(false); - }); - }); - - describe('register', () => { - it('sends correct request and stores tokens', async () => { - const mockData = { - accessToken: 'at-new', - refreshToken: 'rt-new', - user: { id: 'u2', email: 'new@b.com', displayName: 'New', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'nomgap', - storage, - }); - - const result = await client.register('new@b.com', 'pass1234', 'New User'); - - expect(result.user.displayName).toBe('New'); - expect(client.getAccessToken()).toBe('at-new'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.displayName).toBe('New User'); - expect(body.productId).toBe('nomgap'); - }); - }); - - describe('getMe', () => { - it('sends authorization header', async () => { - const mockUser = { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }; - globalThis.fetch = mockFetchResponse(mockUser); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('my-token', 'my-refresh'); - - const user = await client.getMe(); - expect(user.email).toBe('a@b.com'); - - const opts = (globalThis.fetch as ReturnType).mock.calls[0][1]; - expect(opts.headers['Authorization']).toBe('Bearer my-token'); - }); - }); - - describe('refreshAccessToken', () => { - it('refreshes and stores new tokens', async () => { - const mockRefresh = { accessToken: 'at-refreshed', refreshToken: 'rt-refreshed' }; - globalThis.fetch = mockFetchResponse(mockRefresh); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('old-at', 'old-rt'); - - const ok = await client.refreshAccessToken(); - - expect(ok).toBe(true); - expect(client.getAccessToken()).toBe('at-refreshed'); - expect(client.getRefreshToken()).toBe('rt-refreshed'); - }); - - it('clears tokens on refresh failure', async () => { - globalThis.fetch = mockFetchResponse({ error: 'expired' }, 401); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('old-at', 'old-rt'); - - const ok = await client.refreshAccessToken(); - - expect(ok).toBe(false); - expect(client.isAuthenticated()).toBe(false); - }); - - it('returns false if no refresh token', async () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const ok = await client.refreshAccessToken(); - expect(ok).toBe(false); - }); - }); - - describe('forgotPassword', () => { - it('sends email and productId', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Reset email sent' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'mindlyst', - storage, - }); - - const result = await client.forgotPassword('user@test.com'); - expect(result.message).toBe('Reset email sent'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.email).toBe('user@test.com'); - expect(body.productId).toBe('mindlyst'); - }); - }); - - describe('changePassword', () => { - it('sends authenticated request', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Password changed' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.changePassword('old', 'new12345'); - expect(result.message).toBe('Password changed'); - - const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(opts.headers['Authorization']).toBe('Bearer tok'); - const body = JSON.parse(opts.body); - expect(body.currentPassword).toBe('old'); - expect(body.newPassword).toBe('new12345'); - }); - }); - - describe('deleteAccount', () => { - it('clears tokens after deletion', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Deleted' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.deleteAccount('mypassword'); - - expect(client.isAuthenticated()).toBe(false); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toContain('/auth/account'); - expect(opts.method).toBe('DELETE'); - }); - }); - - describe('verifyEmail', () => { - it('sends verification token', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Verified' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyEmail('verify-token-abc'); - expect(result.message).toBe('Verified'); - }); - }); - - describe('resendVerification', () => { - it('sends email and productId', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Sent' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'chronomind', - storage, - }); - - await client.resendVerification('user@test.com'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.email).toBe('user@test.com'); - expect(body.productId).toBe('chronomind'); - }); - }); -}); diff --git a/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts b/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts deleted file mode 100644 index e7c5f17..0000000 --- a/vendor/bytelyst/auth-client/src/__tests__/smartauth.test.ts +++ /dev/null @@ -1,571 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createAuthClient } from '../client.js'; -import type { TokenStorage } from '../types.js'; - -function createMockStorage(): TokenStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - removeItem: (key: string) => store.delete(key), - }; -} - -function mockFetchResponse(data: unknown, status = 200) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - }); -} - -describe('@bytelyst/auth-client — SmartAuth', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - }); - - // ── Phase 1C: Google Sign-In ────────────────────── - - describe('loginWithGoogle', () => { - it('calls POST /auth/oauth/google and stores tokens', async () => { - const mockData = { - accessToken: 'google-at', - refreshToken: 'google-rt', - user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.loginWithGoogle('google-id-token-123'); - - expect('user' in result).toBe(true); - if ('user' in result) { - expect(result.user.email).toBe('g@gmail.com'); - } - expect(client.getAccessToken()).toBe('google-at'); - expect(client.getRefreshToken()).toBe('google-rt'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/google'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.idToken).toBe('google-id-token-123'); - expect(body.productId).toBe('testapp'); - }); - - it('returns MFA challenge when mfaRequired is true', async () => { - const mfaData = { - mfaRequired: true, - mfaChallenge: 'challenge-token-abc', - methods: ['totp'], - }; - globalThis.fetch = mockFetchResponse(mfaData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.loginWithGoogle('google-id-token-456'); - - expect('mfaRequired' in result).toBe(true); - if ('mfaRequired' in result) { - expect(result.mfaRequired).toBe(true); - expect(result.mfaChallenge).toBe('challenge-token-abc'); - expect(result.methods).toEqual(['totp']); - } - // Tokens should NOT be stored when MFA is required - expect(client.isAuthenticated()).toBe(false); - }); - }); - - describe('loginWithMicrosoft', () => { - it('calls POST /auth/oauth/microsoft', async () => { - const mockData = { - accessToken: 'ms-at', - refreshToken: 'ms-rt', - user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await client.loginWithMicrosoft('ms-id-token'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft'); - expect(client.getAccessToken()).toBe('ms-at'); - }); - }); - - describe('loginWithApple', () => { - it('calls POST /auth/oauth/apple', async () => { - const mockData = { - accessToken: 'apple-at', - refreshToken: 'apple-rt', - user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - await client.loginWithApple('apple-id-token'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/oauth/apple'); - expect(client.getAccessToken()).toBe('apple-at'); - }); - }); - - // ── Phase 1C: Provider management ───────────────── - - describe('getProviders', () => { - it('calls GET /auth/providers', async () => { - const providers = { - providers: [ - { - provider: 'google', - email: 'g@gmail.com', - linkedAt: '2025-01-01T00:00:00Z', - lastUsedAt: null, - }, - ], - }; - globalThis.fetch = mockFetchResponse(providers); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.getProviders(); - - expect(result).toHaveLength(1); - expect(result[0].provider).toBe('google'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers'); - expect(opts.method).toBe('GET'); - expect(opts.headers['Authorization']).toBe('Bearer tok'); - }); - }); - - describe('linkProvider', () => { - it('calls POST /auth/providers/link with provider and idToken', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.linkProvider('google', 'link-token'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers/link'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.provider).toBe('google'); - expect(body.idToken).toBe('link-token'); - }); - }); - - describe('unlinkProvider', () => { - it('calls DELETE /auth/providers/:provider', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.unlinkProvider('google'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/providers/google'); - expect(opts.method).toBe('DELETE'); - }); - }); - - // ── Phase 2D: MFA ───────────────────────────────── - - describe('verifyMfa', () => { - it('sends challenge token and TOTP code, stores tokens', async () => { - const mockData = { - accessToken: 'mfa-at', - refreshToken: 'mfa-rt', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyMfa('challenge-xyz', '123456', 'totp'); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('mfa-at'); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.challengeToken).toBe('challenge-xyz'); - expect(body.code).toBe('123456'); - expect(body.method).toBe('totp'); - }); - }); - - describe('setupTotp', () => { - it('calls POST /auth/mfa/setup', async () => { - const setup = { - secret: 'ABCDEFGH', - otpauthUri: 'otpauth://totp/Test?secret=ABC', - recoveryCodes: ['code1', 'code2'], - }; - globalThis.fetch = mockFetchResponse(setup); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.setupTotp(); - - expect(result.otpauthUri).toContain('otpauth://'); - expect(result.secret).toBe('ABCDEFGH'); - expect(result.recoveryCodes).toHaveLength(2); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/mfa/setup'); - }); - }); - - describe('getMfaStatus', () => { - it('returns MFA status', async () => { - const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 }; - globalThis.fetch = mockFetchResponse(status); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.getMfaStatus(); - expect(result.mfaEnabled).toBe(true); - expect(result.methods).toContain('totp'); - }); - }); - - // ── Phase 3: Passkeys ───────────────────────────── - - describe('listPasskeys', () => { - it('calls GET /auth/passkeys and unwraps response', async () => { - const data = { - passkeys: [ - { - credentialId: 'pk1', - friendlyName: 'MacBook', - deviceType: 'singleDevice', - backedUp: true, - lastUsedAt: '2025-01-01T00:00:00Z', - createdAt: '2025-01-01T00:00:00Z', - }, - ], - }; - globalThis.fetch = mockFetchResponse(data); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.listPasskeys(); - expect(result).toHaveLength(1); - expect(result[0].friendlyName).toBe('MacBook'); - expect(result[0].credentialId).toBe('pk1'); - }); - }); - - describe('verifyPasskeyAuth', () => { - it('stores tokens after passkey authentication', async () => { - const mockData = { - accessToken: 'pk-at', - refreshToken: 'pk-rt', - user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' }, - }; - globalThis.fetch = mockFetchResponse(mockData); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.verifyPasskeyAuth({ id: 'cred-1', response: {} }); - - expect(result.user.email).toBe('a@b.com'); - expect(client.getAccessToken()).toBe('pk-at'); - - const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify'); - }); - }); - - // ── Phase 3: Devices ────────────────────────────── - - describe('listDevices', () => { - it('calls GET /auth/devices and unwraps response', async () => { - const data = { - devices: [ - { - fingerprint: 'fp-abc', - trustLevel: 'trusted', - deviceInfo: { platform: 'web' }, - lastIp: '1.2.3.4', - trustExpiresAt: '2025-04-01T00:00:00Z', - createdAt: '2025-01-01T00:00:00Z', - lastSeenAt: '2025-01-01T00:00:00Z', - isTrusted: true, - }, - ], - }; - globalThis.fetch = mockFetchResponse(data); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.listDevices(); - expect(result).toHaveLength(1); - expect(result[0].trustLevel).toBe('trusted'); - expect(result[0].fingerprint).toBe('fp-abc'); - }); - }); - - describe('trustDevice', () => { - it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.trustDevice('fp-abc', 'trusted', { platform: 'web' }); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/trust'); - expect(opts.method).toBe('POST'); - const body = JSON.parse(opts.body); - expect(body.fingerprint).toBe('fp-abc'); - expect(body.trustLevel).toBe('trusted'); - expect(body.deviceInfo).toEqual({ platform: 'web' }); - }); - }); - - describe('revokeDevice', () => { - it('calls DELETE /auth/devices/:fingerprint', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.revokeDevice('fp-xyz'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz'); - expect(opts.method).toBe('DELETE'); - }); - }); - - describe('revokeAllDevices', () => { - it('calls POST /auth/devices/revoke-all', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - await client.revokeAllDevices(); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all'); - expect(opts.method).toBe('POST'); - }); - }); - - // ── Phase 5B: Admin security ────────────────────── - - describe('getSecurityOverview', () => { - it('calls GET /auth/security/overview', async () => { - const overview = { - totalUsers: 100, - mfaAdoptionPercent: 42.5, - providerDistribution: { google: 60, microsoft: 30, password: 10 }, - activeSessions: 250, - suspiciousEvents24h: 3, - }; - globalThis.fetch = mockFetchResponse(overview); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('admin-tok', 'admin-ref'); - - const result = await client.getSecurityOverview(); - expect(result.totalUsers).toBe(100); - expect(result.mfaAdoptionPercent).toBe(42.5); - }); - }); - - describe('unlockUser', () => { - it('calls POST /auth/users/:id/unlock', async () => { - globalThis.fetch = mockFetchResponse({}); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('admin-tok', 'admin-ref'); - - await client.unlockUser('user-locked-123'); - - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock'); - expect(opts.method).toBe('POST'); - }); - }); - - describe('cancelDeletion', () => { - it('calls POST /auth/account/cancel-deletion', async () => { - globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' }); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - client.setTokens('tok', 'ref'); - - const result = await client.cancelDeletion(); - expect(result.message).toBe('Deletion cancelled'); - }); - }); - - // ── login() MFA flow ────────────────────────────── - - describe('login with MFA challenge', () => { - it('returns MfaLoginResult and does not store tokens', async () => { - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'login-challenge', - methods: ['totp', 'recovery'], - }; - globalThis.fetch = mockFetchResponse(mfaResponse); - - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - const result = await client.login('user@test.com', 'password'); - - expect('mfaRequired' in result).toBe(true); - if ('mfaRequired' in result) { - expect(result.mfaChallenge).toBe('login-challenge'); - expect(result.methods).toEqual(['totp', 'recovery']); - } - expect(client.isAuthenticated()).toBe(false); - }); - }); - - // ── createAuthClient includes all SmartAuth methods ── - - describe('client exposes all SmartAuth methods', () => { - it('has all phase 1C, 2D, 3, 5B methods', () => { - const client = createAuthClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - storage, - }); - - // Phase 1C - expect(client.loginWithGoogle).toBeTypeOf('function'); - expect(client.loginWithMicrosoft).toBeTypeOf('function'); - expect(client.loginWithApple).toBeTypeOf('function'); - expect(client.getProviders).toBeTypeOf('function'); - expect(client.linkProvider).toBeTypeOf('function'); - expect(client.unlinkProvider).toBeTypeOf('function'); - // Phase 2D - expect(client.verifyMfa).toBeTypeOf('function'); - expect(client.setupTotp).toBeTypeOf('function'); - expect(client.verifyTotpSetup).toBeTypeOf('function'); - expect(client.disableMfa).toBeTypeOf('function'); - expect(client.getMfaStatus).toBeTypeOf('function'); - expect(client.regenerateRecoveryCodes).toBeTypeOf('function'); - // Phase 3 - expect(client.getPasskeyRegisterOptions).toBeTypeOf('function'); - expect(client.verifyPasskeyRegistration).toBeTypeOf('function'); - expect(client.getPasskeyAuthOptions).toBeTypeOf('function'); - expect(client.verifyPasskeyAuth).toBeTypeOf('function'); - expect(client.listPasskeys).toBeTypeOf('function'); - expect(client.deletePasskey).toBeTypeOf('function'); - expect(client.listDevices).toBeTypeOf('function'); - expect(client.trustDevice).toBeTypeOf('function'); - expect(client.revokeDevice).toBeTypeOf('function'); - expect(client.revokeAllDevices).toBeTypeOf('function'); - // Phase 5B - expect(client.getSecurityOverview).toBeTypeOf('function'); - expect(client.unlockUser).toBeTypeOf('function'); - expect(client.exportAuthData).toBeTypeOf('function'); - expect(client.cancelDeletion).toBeTypeOf('function'); - }); - }); -}); diff --git a/vendor/bytelyst/auth-client/src/client.ts b/vendor/bytelyst/auth-client/src/client.ts deleted file mode 100644 index 10e1161..0000000 --- a/vendor/bytelyst/auth-client/src/client.ts +++ /dev/null @@ -1,527 +0,0 @@ -/** - * Browser/React Native-safe auth API client for platform-service. - * - * Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc. - * No Node.js dependencies — uses globalThis.fetch and configurable storage. - * - * @example - * ```ts - * import { createAuthClient } from '@bytelyst/auth-client'; - * - * const auth = createAuthClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'chronomind', - * }); - * - * const result = await auth.login('user@example.com', 'password123'); - * console.log(result.user.displayName); - * ``` - */ - -import type { - AuthClient, - AuthClientConfig, - AuthProvider, - AuthResult, - AuthUser, - Device, - LoginEventInfo, - MfaRequiredResult, - MfaStatus, - Passkey, - SecurityOverview, - TokenStorage, - TotpSetupResult, -} from './types.js'; - -// ── Default localStorage adapter ───────────────────────────────── - -/** - * No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js). - * Tokens stored via noopStorage are NOT persisted — they are lost on page reload. - * For server-side rendering, use cookie-based auth instead of relying on this client. - */ -const noopStorage: TokenStorage = { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, -}; - -function getDefaultStorage(): TokenStorage { - if ( - typeof globalThis.localStorage !== 'undefined' && - typeof globalThis.localStorage?.getItem === 'function' - ) { - return globalThis.localStorage; - } - return noopStorage; -} - -// ── UUID helper (browser + RN safe) ────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────────── - -export function createAuthClient(config: AuthClientConfig): AuthClient { - const { baseUrl, productId, timeoutMs = 15_000 } = config; - const storage = config.storage ?? getDefaultStorage(); - const prefix = config.storagePrefix ?? productId; - - const KEYS = { - accessToken: `${prefix}-auth-token`, - refreshToken: `${prefix}-refresh-token`, - } as const; - - // ── Token management ──────────────────────────── - - function getAccessToken(): string | null { - return storage.getItem(KEYS.accessToken); - } - - function getRefreshToken(): string | null { - return storage.getItem(KEYS.refreshToken); - } - - function setTokens(accessToken: string, refreshToken: string): void { - storage.setItem(KEYS.accessToken, accessToken); - storage.setItem(KEYS.refreshToken, refreshToken); - } - - function clearTokens(): void { - storage.removeItem(KEYS.accessToken); - storage.removeItem(KEYS.refreshToken); - } - - function isAuthenticated(): boolean { - return getAccessToken() !== null; - } - - // ── HTTP helper ───────────────────────────────── - - async function request( - path: string, - method: string, - body?: unknown, - opts?: { skipAuth?: boolean } - ): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }; - - if (!opts?.skipAuth) { - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error( - (data as Record).message || - (data as Record).error || - `HTTP ${res.status}` - ); - } - - if (res.status === 204) return undefined as T; - return res.json() as Promise; - } finally { - clearTimeout(timer); - } - } - - // ── Singleton refresh guard ───────────────────── - - let _refreshPromise: Promise | null = null; - - async function refreshAccessToken(): Promise { - if (_refreshPromise) return _refreshPromise; - - _refreshPromise = (async () => { - const rt = getRefreshToken(); - if (!rt) return false; - - try { - const data = await request<{ accessToken: string; refreshToken: string }>( - '/auth/refresh', - 'POST', - { refreshToken: rt }, - { skipAuth: true } - ); - setTokens(data.accessToken, data.refreshToken); - return true; - } catch { - clearTokens(); - return false; - } - })(); - - try { - return await _refreshPromise; - } finally { - _refreshPromise = null; - } - } - - // ── Auth operations ───────────────────────────── - - async function login(email: string, password: string): Promise { - const result = await request('/auth/login', 'POST', { - email, - password, - productId, - }); - if ('mfaRequired' in result && result.mfaRequired) { - return result; - } - const authResult = result as AuthResult; - setTokens(authResult.accessToken, authResult.refreshToken); - return authResult; - } - - async function register( - email: string, - password: string, - displayName: string - ): Promise { - const result = await request('/auth/register', 'POST', { - email, - password, - displayName, - productId, - }); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function getMe(): Promise { - return request('/auth/me', 'GET'); - } - - // ── Password management ───────────────────────── - - async function forgotPassword(email: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/forgot-password', 'POST', { - email, - productId, - }); - } - - async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/reset-password', 'POST', { - token, - newPassword, - }); - } - - async function changePassword( - currentPassword: string, - newPassword: string - ): Promise<{ message: string }> { - return request<{ message: string }>('/auth/change-password', 'POST', { - currentPassword, - newPassword, - }); - } - - // ── Account management ────────────────────────── - - async function deleteAccount(password: string): Promise<{ message: string }> { - const result = await request<{ message: string }>('/auth/account', 'DELETE', { - password, - }); - clearTokens(); - return result; - } - - // ── Email verification ────────────────────────── - - async function verifyEmail(token: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/verify-email', 'POST', { token }); - } - - async function resendVerification(email: string): Promise<{ message: string }> { - return request<{ message: string }>('/auth/resend-verification', 'POST', { - email, - productId, - }); - } - - // ── OAuth / Social login (Phase 1C) ──────────────── - - async function loginWithOAuth( - provider: string, - idToken: string - ): Promise { - const result = await request( - `/auth/oauth/${provider}`, - 'POST', - { idToken, productId }, - { skipAuth: true } - ); - if ('mfaRequired' in result && result.mfaRequired) { - return result; - } - const authResult = result as AuthResult; - setTokens(authResult.accessToken, authResult.refreshToken); - return authResult; - } - - async function loginWithGoogle(idToken: string): Promise { - return loginWithOAuth('google', idToken); - } - - async function loginWithMicrosoft(idToken: string): Promise { - return loginWithOAuth('microsoft', idToken); - } - - async function loginWithApple(idToken: string): Promise { - return loginWithOAuth('apple', idToken); - } - - // ── Provider management (Phase 1C) ───────────────── - - async function getProviders(): Promise { - const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET'); - return data.providers; - } - - async function linkProvider(provider: string, idToken: string): Promise { - await request('/auth/providers/link', 'POST', { provider, idToken }); - } - - async function unlinkProvider(provider: string): Promise { - await request(`/auth/providers/${provider}`, 'DELETE'); - } - - // ── MFA (Phase 2D) ───────────────────────────────── - - async function verifyMfa( - challengeToken: string, - code: string, - method: 'totp' | 'recovery' - ): Promise { - const result = await request('/auth/mfa/verify', 'POST', { - challengeToken, - code, - method, - }); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function setupTotp(): Promise { - return request('/auth/mfa/setup', 'POST'); - } - - async function verifyTotpSetup(code: string): Promise { - await request('/auth/mfa/verify-setup', 'POST', { code }); - } - - async function disableMfa(code: string): Promise { - await request('/auth/mfa/disable', 'POST', { code }); - } - - async function getMfaStatus(): Promise { - return request('/auth/mfa/status', 'GET'); - } - - async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> { - return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST'); - } - - // ── Passkeys (Phase 3) ───────────────────────────── - - async function getPasskeyRegisterOptions(): Promise { - return request('/auth/passkeys/register/options', 'POST'); - } - - async function verifyPasskeyRegistration(response: unknown): Promise { - await request('/auth/passkeys/register/verify', 'POST', response); - } - - async function getPasskeyAuthOptions(): Promise { - return request('/auth/passkeys/authenticate/options', 'POST', undefined, { - skipAuth: true, - }); - } - - async function verifyPasskeyAuth(response: unknown): Promise { - const result = await request( - '/auth/passkeys/authenticate/verify', - 'POST', - response, - { skipAuth: true } - ); - setTokens(result.accessToken, result.refreshToken); - return result; - } - - async function listPasskeys(): Promise { - const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET'); - return data.passkeys; - } - - async function deletePasskey(id: string): Promise { - await request(`/auth/passkeys/${id}`, 'DELETE'); - } - - // ── Devices (Phase 3) ────────────────────────────── - - async function listDevices(): Promise { - const data = await request<{ devices: Device[] }>('/auth/devices', 'GET'); - return data.devices; - } - - async function trustDevice( - fingerprint: string, - trustLevel: 'trusted' | 'remembered', - deviceInfo?: Record - ): Promise { - await request('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo }); - } - - async function revokeDevice(fingerprint: string): Promise { - await request(`/auth/devices/${fingerprint}`, 'DELETE'); - } - - async function revokeAllDevices(): Promise { - await request('/auth/devices/revoke-all', 'POST'); - } - - // ── Admin security (Phase 5B) ────────────────────── - - async function getSecurityOverview(): Promise { - return request('/auth/security/overview', 'GET'); - } - - async function unlockUser(userId: string): Promise { - await request(`/auth/users/${userId}/unlock`, 'POST'); - } - - async function exportAuthData(): Promise { - return request('/auth/export', 'GET'); - } - - async function cancelDeletion(): Promise<{ message: string }> { - return request<{ message: string }>('/auth/account/cancel-deletion', 'POST'); - } - - // ── Step-up auth ──────────────────────────────────── - - async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> { - return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential }); - } - - // ── Login history ─────────────────────────────────── - - async function getLoginHistory(limit = 50): Promise { - const data = await request<{ events: LoginEventInfo[] }>( - `/auth/login-events?limit=${limit}`, - 'GET' - ); - return data.events; - } - - // ── Admin security ────────────────────────────────── - - async function getAdminLoginEvents(opts?: { - userId?: string; - suspicious?: boolean; - limit?: number; - }): Promise { - const params = new URLSearchParams(); - if (opts?.userId) params.set('userId', opts.userId); - if (opts?.suspicious) params.set('suspicious', 'true'); - if (opts?.limit) params.set('limit', String(opts.limit)); - const qs = params.toString(); - const data = await request<{ events: LoginEventInfo[] }>( - `/auth/login-events/admin${qs ? `?${qs}` : ''}`, - 'GET' - ); - return data.events; - } - - async function getAdminDevices(userId: string): Promise { - const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET'); - return data.devices; - } - - return { - getAccessToken, - getRefreshToken, - setTokens, - clearTokens, - isAuthenticated, - login, - register, - getMe, - refreshAccessToken, - forgotPassword, - resetPassword, - changePassword, - deleteAccount, - verifyEmail, - resendVerification, - // OAuth / Social login (Phase 1C) - loginWithGoogle, - loginWithMicrosoft, - loginWithApple, - // Provider management (Phase 1C) - getProviders, - linkProvider, - unlinkProvider, - // MFA (Phase 2D) - verifyMfa, - setupTotp, - verifyTotpSetup, - disableMfa, - getMfaStatus, - regenerateRecoveryCodes, - // Passkeys (Phase 3) - getPasskeyRegisterOptions, - verifyPasskeyRegistration, - getPasskeyAuthOptions, - verifyPasskeyAuth, - listPasskeys, - deletePasskey, - // Devices (Phase 3) - listDevices, - trustDevice, - revokeDevice, - revokeAllDevices, - // Admin security (Phase 5B) - getSecurityOverview, - unlockUser, - exportAuthData, - cancelDeletion, - // Step-up auth - stepUp, - // Login history - getLoginHistory, - // Admin queries - getAdminLoginEvents, - getAdminDevices, - }; -} diff --git a/vendor/bytelyst/auth-client/src/index.ts b/vendor/bytelyst/auth-client/src/index.ts deleted file mode 100644 index 9592e2e..0000000 --- a/vendor/bytelyst/auth-client/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { createAuthClient } from './client.js'; -export type { - AuthClient, - AuthClientConfig, - AuthProvider, - AuthResult, - AuthUser, - Device, - LoginEventInfo, - MfaRequiredResult, - MfaStatus, - Passkey, - SecurityOverview, - TokenStorage, - TotpSetupResult, -} from './types.js'; diff --git a/vendor/bytelyst/auth-client/src/types.ts b/vendor/bytelyst/auth-client/src/types.ts deleted file mode 100644 index 3f760b5..0000000 --- a/vendor/bytelyst/auth-client/src/types.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Types for @bytelyst/auth-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface AuthClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api" or "https://api.example.com"). */ - baseUrl: string; - - /** Product identifier sent with every request as x-product-id header. */ - productId: string; - - /** Storage adapter for tokens. Defaults to localStorage if available. */ - storage?: TokenStorage; - - /** Optional prefix for storage keys. Default: product ID. */ - storagePrefix?: string; - - /** Request timeout in milliseconds. Default: 15000. */ - timeoutMs?: number; -} - -export interface TokenStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} - -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: string; - plan: string; - mfaEnabled?: boolean; - mfaMethods?: string[]; - providers?: string[]; - products?: string[]; -} - -export interface AuthResult { - accessToken: string; - refreshToken: string; - user: AuthUser; -} - -export interface MfaRequiredResult { - mfaRequired: true; - mfaChallenge: string; - methods: string[]; -} - -export interface TotpSetupResult { - secret: string; - otpauthUri: string; - recoveryCodes: string[]; -} - -export interface MfaStatus { - mfaEnabled: boolean; - methods: string[]; - recoveryCodesRemaining: number; -} - -export interface AuthProvider { - provider: string; - email: string; - linkedAt: string; - lastUsedAt: string | null; -} - -export interface Passkey { - credentialId: string; - friendlyName: string; - deviceType: string; - backedUp: boolean; - lastUsedAt: string; - createdAt: string; -} - -export interface Device { - fingerprint: string; - trustLevel: 'trusted' | 'remembered' | 'unknown'; - deviceInfo: Record; - lastIp?: string; - lastLocation?: string; - trustExpiresAt: string; - createdAt: string; - lastSeenAt: string; - isTrusted: boolean; -} - -export interface LoginEventInfo { - id: string; - result: string; - method: string; - ip: string; - userAgent?: string; - fingerprint?: string; - location?: string; - riskLevel: string; - riskScore: number; - riskFlags: string[]; - createdAt: string; -} - -export interface SecurityOverview { - totalUsers: number; - mfaAdoptionPercent: number; - providerDistribution: Record; - activeSessions: number; - suspiciousEvents24h: number; -} - -export interface AuthClient { - // ── Token management ──────────────────────────── - getAccessToken(): string | null; - getRefreshToken(): string | null; - setTokens(accessToken: string, refreshToken: string): void; - clearTokens(): void; - isAuthenticated(): boolean; - - // ── Auth operations ───────────────────────────── - login(email: string, password: string): Promise; - register(email: string, password: string, displayName: string): Promise; - getMe(): Promise; - refreshAccessToken(): Promise; - - // ── OAuth / Social login ──────────────────────── - loginWithGoogle(idToken: string): Promise; - loginWithMicrosoft(idToken: string): Promise; - loginWithApple(idToken: string): Promise; - - // ── Provider management ───────────────────────── - getProviders(): Promise; - linkProvider(provider: string, idToken: string): Promise; - unlinkProvider(provider: string): Promise; - - // ── MFA ───────────────────────────────────────── - verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise; - setupTotp(): Promise; - verifyTotpSetup(code: string): Promise; - disableMfa(code: string): Promise; - getMfaStatus(): Promise; - regenerateRecoveryCodes(): Promise<{ codes: string[] }>; - - // ── Passkeys (WebAuthn) ───────────────────────── - getPasskeyRegisterOptions(): Promise; - verifyPasskeyRegistration(response: unknown): Promise; - getPasskeyAuthOptions(): Promise; - verifyPasskeyAuth(response: unknown): Promise; - listPasskeys(): Promise; - deletePasskey(id: string): Promise; - - // ── Devices ───────────────────────────────────── - listDevices(): Promise; - trustDevice( - fingerprint: string, - trustLevel: 'trusted' | 'remembered', - deviceInfo?: Record - ): Promise; - revokeDevice(fingerprint: string): Promise; - revokeAllDevices(): Promise; - - // ── Step-up auth ──────────────────────────────── - stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>; - - // ── Login history ─────────────────────────────── - getLoginHistory(limit?: number): Promise; - - // ── Admin security ────────────────────────────── - getSecurityOverview(): Promise; - unlockUser(userId: string): Promise; - getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise; - getAdminDevices(userId: string): Promise; - exportAuthData(): Promise; - - // ── Password management ───────────────────────── - forgotPassword(email: string): Promise<{ message: string }>; - resetPassword(token: string, newPassword: string): Promise<{ message: string }>; - changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }>; - - // ── Account management ────────────────────────── - deleteAccount(password: string): Promise<{ message: string }>; - cancelDeletion(): Promise<{ message: string }>; - - // ── Email verification ────────────────────────── - verifyEmail(token: string): Promise<{ message: string }>; - resendVerification(email: string): Promise<{ message: string }>; -} diff --git a/vendor/bytelyst/auth-client/tsconfig.json b/vendor/bytelyst/auth-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/auth-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth-ui/package.json b/vendor/bytelyst/auth-ui/package.json deleted file mode 100644 index 3f6285a..0000000 --- a/vendor/bytelyst/auth-ui/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@bytelyst/auth-ui", - "version": "0.1.5", - "type": "module", - "description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx b/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx deleted file mode 100644 index 26a28d2..0000000 --- a/vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { AuthPageLayoutProps } from './types.js'; - -/** - * Full-page auth layout — centered card with product branding. - * Wraps any auth form (LoginForm, RegisterForm, etc.). - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function AuthPageLayout({ - productName, - logo, - title, - subtitle, - children, - footer, - className, -}: AuthPageLayoutProps) { - return ( -
-
- {/* Branding */} -
- {logo && ( -
- {typeof logo === 'string' ? ( - {productName} - ) : ( - logo - )} -
- )} -
- {productName} -
-
- {title} -
- {subtitle && ( -
- {subtitle} -
- )} -
- - {/* Form content */} - {children} - - {/* Footer */} - {footer && ( -
- {footer} -
- )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx deleted file mode 100644 index 94c7125..0000000 --- a/vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { ForgotPasswordFormProps } from './types.js'; - -/** - * Forgot password form — email input to request a reset link. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function ForgotPasswordForm({ - onSubmit, - isLoading = false, - error, - success, - onBack, - className, -}: ForgotPasswordFormProps) { - const [email, setEmail] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(email); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
-
- Enter your email address and we'll send you a link to reset your password. -
- - setEmail(e.target.value)} - required - disabled={isLoading} - data-testid="bl-forgot-email" - style={inputStyle} - /> - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - - {onBack && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/LoginForm.tsx b/vendor/bytelyst/auth-ui/src/LoginForm.tsx deleted file mode 100644 index d4fbbc8..0000000 --- a/vendor/bytelyst/auth-ui/src/LoginForm.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { SocialButtons } from './SocialButtons.js'; -import type { LoginFormProps } from './types.js'; - -/** - * Email/password login form with optional social login buttons. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function LoginForm({ - onSubmit, - providers, - onSocialLogin, - isLoading = false, - error, - className, -}: LoginFormProps) { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(email, password); - } - - return ( -
-
- setEmail(e.target.value)} - required - disabled={isLoading} - data-testid="bl-login-email" - style={{ - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - }} - /> - setPassword(e.target.value)} - required - disabled={isLoading} - data-testid="bl-login-password" - style={{ - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - }} - /> - - {error && ( -
- {error} -
- )} - - -
- - {providers && providers.length > 0 && onSocialLogin && ( - <> -
-
- or -
-
- - - )} -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx b/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx deleted file mode 100644 index 289cfe0..0000000 --- a/vendor/bytelyst/auth-ui/src/MfaChallenge.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { MfaChallengeProps } from './types.js'; - -/** - * MFA code entry form (6-digit TOTP or recovery code). - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function MfaChallenge({ - onSubmit, - onUseRecovery, - methods, - isLoading = false, - error, - className, -}: MfaChallengeProps) { - const [code, setCode] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(code); - } - - return ( -
-
-
- Enter your authentication code -
- - {methods && methods.length > 0 && ( -
- Available methods: {methods.join(', ')} -
- )} - - setCode(e.target.value)} - required - disabled={isLoading} - maxLength={8} - data-testid="bl-mfa-code" - style={{ - padding: '12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '24px', - textAlign: 'center', - letterSpacing: '4px', - fontFamily: 'monospace', - }} - /> - - {error && ( -
- {error} -
- )} - - - - {onUseRecovery && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx b/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx deleted file mode 100644 index a0d9b5a..0000000 --- a/vendor/bytelyst/auth-ui/src/OnboardingShell.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import type { OnboardingShellProps } from './types.js'; - -/** - * Onboarding shell — step indicator, navigation, progress bar. - * Wraps arbitrary step content provided via children. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function OnboardingShell({ - steps, - currentStep, - onNext, - onBack, - onComplete, - children, - className, -}: OnboardingShellProps) { - const isFirst = currentStep === 0; - const isLast = currentStep === steps.length - 1; - const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100; - - return ( -
- {/* Progress bar */} -
-
-
- - {/* Step indicator */} -
- {steps.map((step, i) => ( -
- - {i < currentStep ? '✓' : i + 1} - - {step.label} -
- ))} -
- - {/* Step content */} -
- {children} -
- - {/* Navigation */} -
- - - -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx b/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx deleted file mode 100644 index 9c3e2b6..0000000 --- a/vendor/bytelyst/auth-ui/src/PasswordStrengthBar.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useMemo } from 'react'; -import type { PasswordStrength } from './types.js'; - -interface PasswordStrengthBarProps { - password: string; - className?: string; -} - -const STRENGTH_CONFIG: Record = { - weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' }, - fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' }, - good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' }, - strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' }, -}; - -export function getPasswordStrength(password: string): PasswordStrength { - let score = 0; - if (password.length >= 8) score++; - if (password.length >= 12) score++; - if (/[A-Z]/.test(password)) score++; - if (/[a-z]/.test(password)) score++; - if (/\d/.test(password)) score++; - if (/[^A-Za-z0-9]/.test(password)) score++; - - if (score <= 2) return 'weak'; - if (score <= 3) return 'fair'; - if (score <= 4) return 'good'; - return 'strong'; -} - -export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) { - const strength = useMemo(() => getPasswordStrength(password), [password]); - const config = STRENGTH_CONFIG[strength]; - const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength]; - - if (!password) return null; - - return ( -
-
-
-
-
- {config.label} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/RegisterForm.tsx b/vendor/bytelyst/auth-ui/src/RegisterForm.tsx deleted file mode 100644 index 0ab1b1b..0000000 --- a/vendor/bytelyst/auth-ui/src/RegisterForm.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { PasswordStrengthBar } from './PasswordStrengthBar.js'; -import type { RegisterFormProps } from './types.js'; - -/** - * Registration form with name, email, password, confirm password, - * password strength indicator, and optional terms checkbox. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function RegisterForm({ - onSubmit, - isLoading = false, - error, - termsUrl, - privacyUrl, - onSwitchToLogin, - className, -}: RegisterFormProps) { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - const [termsAccepted, setTermsAccepted] = useState(!termsUrl); - - const passwordMismatch = confirm.length > 0 && password !== confirm; - const canSubmit = - name.trim().length > 0 && - email.length > 0 && - password.length >= 8 && - !passwordMismatch && - termsAccepted && - !isLoading; - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!canSubmit) return; - onSubmit({ name: name.trim(), email, password }); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
- - - - - - - - - - - {passwordMismatch && ( -
- Passwords do not match -
- )} - - {termsUrl && ( - - )} - - {error && ( -
- {error} -
- )} - - - - {onSwitchToLogin && ( -
- Already have an account?{' '} - -
- )} - -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx b/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx deleted file mode 100644 index bd6454e..0000000 --- a/vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import { PasswordStrengthBar } from './PasswordStrengthBar.js'; -import type { ResetPasswordFormProps } from './types.js'; - -/** - * Reset password form — new password + confirm, with strength indicator. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function ResetPasswordForm({ - onSubmit, - isLoading = false, - error, - success, - className, -}: ResetPasswordFormProps) { - const [password, setPassword] = useState(''); - const [confirm, setConfirm] = useState(''); - - const passwordMismatch = confirm.length > 0 && password !== confirm; - const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading; - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - if (!canSubmit) return; - onSubmit(password); - } - - const inputStyle = { - padding: '10px 12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '14px', - width: '100%', - boxSizing: 'border-box' as const, - }; - - return ( -
-
-
- Enter your new password. -
- - - - - - - - {passwordMismatch && ( -
- Passwords do not match -
- )} - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/SocialButtons.tsx b/vendor/bytelyst/auth-ui/src/SocialButtons.tsx deleted file mode 100644 index 8e0a67a..0000000 --- a/vendor/bytelyst/auth-ui/src/SocialButtons.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { SocialButtonsProps, SocialProvider } from './types.js'; - -const PROVIDER_LABELS: Record = { - google: 'Google', - microsoft: 'Microsoft', - apple: 'Apple', -}; - -/** - * Renders social login buttons for the configured providers. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function SocialButtons({ - providers, - onSelect, - disabled = false, - className, -}: SocialButtonsProps) { - return ( -
- {providers.map(provider => ( - - ))} -
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx b/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx deleted file mode 100644 index fce0746..0000000 --- a/vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState, type FormEvent } from 'react'; -import type { VerifyEmailFormProps } from './types.js'; - -/** - * Email verification form — 6-digit code input with resend option. - * Styled via CSS custom properties (inherits --bl-* from host app). - */ -export function VerifyEmailForm({ - onSubmit, - onResend, - isLoading = false, - error, - success, - email, - className, -}: VerifyEmailFormProps) { - const [code, setCode] = useState(''); - - function handleSubmit(e: FormEvent) { - e.preventDefault(); - onSubmit(code); - } - - return ( -
-
-
- Enter the 6-digit code sent to {email ? {email} : 'your email'}. -
- - setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - required - disabled={isLoading} - maxLength={6} - data-testid="bl-verify-code" - style={{ - padding: '12px', - border: '1px solid var(--bl-border, #ccc)', - borderRadius: 'var(--bl-radius, 6px)', - fontSize: '24px', - textAlign: 'center', - letterSpacing: '6px', - fontFamily: 'monospace', - width: '100%', - boxSizing: 'border-box', - }} - /> - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - - - {onResend && ( - - )} -
-
- ); -} diff --git a/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx deleted file mode 100644 index 81eb973..0000000 --- a/vendor/bytelyst/auth-ui/src/__tests__/auth-ui.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { LoginForm } from '../LoginForm.js'; -import { MfaChallenge } from '../MfaChallenge.js'; -import { SocialButtons } from '../SocialButtons.js'; - -describe('@bytelyst/auth-ui', () => { - beforeEach(() => { - cleanup(); - }); - - describe('SocialButtons', () => { - it('renders buttons for each provider', () => { - const onSelect = vi.fn(); - render(); - - expect(screen.getByTestId('bl-social-google')).toBeDefined(); - expect(screen.getByTestId('bl-social-microsoft')).toBeDefined(); - expect(screen.getByTestId('bl-social-apple')).toBeDefined(); - expect(screen.getByText('Continue with Google')).toBeDefined(); - expect(screen.getByText('Continue with Microsoft')).toBeDefined(); - expect(screen.getByText('Continue with Apple')).toBeDefined(); - }); - - it('calls onSelect with provider when clicked', () => { - const onSelect = vi.fn(); - render(); - - fireEvent.click(screen.getByTestId('bl-social-google')); - expect(onSelect).toHaveBeenCalledWith('google'); - }); - - it('disables buttons when disabled prop is true', () => { - const onSelect = vi.fn(); - render(); - - const btn = screen.getByTestId('bl-social-google'); - expect(btn.getAttribute('disabled')).toBe(''); - }); - }); - - describe('LoginForm', () => { - it('renders email, password, and submit button', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-login-email')).toBeDefined(); - expect(screen.getByTestId('bl-login-password')).toBeDefined(); - expect(screen.getByTestId('bl-login-submit')).toBeDefined(); - expect(screen.getByText('Sign in')).toBeDefined(); - }); - - it('calls onSubmit with email and password', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-login-email'), { - target: { value: 'test@example.com' }, - }); - fireEvent.change(screen.getByTestId('bl-login-password'), { - target: { value: 'password123' }, - }); - fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123'); - }); - - it('displays error message', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-login-error')).toBeDefined(); - expect(screen.getByText('Invalid credentials')).toBeDefined(); - }); - - it('renders social buttons when providers are given', () => { - const onSubmit = vi.fn(); - const onSocialLogin = vi.fn(); - render( - - ); - - expect(screen.getByTestId('bl-social-google')).toBeDefined(); - expect(screen.getByTestId('bl-social-apple')).toBeDefined(); - - fireEvent.click(screen.getByTestId('bl-social-google')); - expect(onSocialLogin).toHaveBeenCalledWith('google'); - }); - - it('shows loading state', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByText('Signing in...')).toBeDefined(); - const btn = screen.getByTestId('bl-login-submit'); - expect(btn.getAttribute('disabled')).toBe(''); - }); - }); - - describe('MfaChallenge', () => { - it('renders code input and verify button', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-code')).toBeDefined(); - expect(screen.getByTestId('bl-mfa-submit')).toBeDefined(); - expect(screen.getByText('Verify')).toBeDefined(); - }); - - it('calls onSubmit with code', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-mfa-code'), { - target: { value: '123456' }, - }); - fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith('123456'); - }); - - it('displays available methods', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-methods')).toBeDefined(); - expect(screen.getByText('Available methods: totp, recovery')).toBeDefined(); - }); - - it('shows recovery code button when handler provided', () => { - const onSubmit = vi.fn(); - const onUseRecovery = vi.fn(); - render(); - - const recoveryBtn = screen.getByTestId('bl-mfa-recovery'); - expect(recoveryBtn).toBeDefined(); - - fireEvent.click(recoveryBtn); - expect(onUseRecovery).toHaveBeenCalledOnce(); - }); - - it('displays error message', () => { - const onSubmit = vi.fn(); - render(); - - expect(screen.getByTestId('bl-mfa-error')).toBeDefined(); - expect(screen.getByText('Invalid code')).toBeDefined(); - }); - }); -}); diff --git a/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx b/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx deleted file mode 100644 index 6134b61..0000000 --- a/vendor/bytelyst/auth-ui/src/__tests__/new-components.test.tsx +++ /dev/null @@ -1,402 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { RegisterForm } from '../RegisterForm.js'; -import { ForgotPasswordForm } from '../ForgotPasswordForm.js'; -import { ResetPasswordForm } from '../ResetPasswordForm.js'; -import { VerifyEmailForm } from '../VerifyEmailForm.js'; -import { OnboardingShell } from '../OnboardingShell.js'; -import { AuthPageLayout } from '../AuthPageLayout.js'; -import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js'; - -describe('RegisterForm', () => { - beforeEach(() => cleanup()); - - it('renders all fields', () => { - render(); - expect(screen.getByTestId('bl-register-name')).toBeDefined(); - expect(screen.getByTestId('bl-register-email')).toBeDefined(); - expect(screen.getByTestId('bl-register-password')).toBeDefined(); - expect(screen.getByTestId('bl-register-confirm')).toBeDefined(); - expect(screen.getByTestId('bl-register-submit')).toBeDefined(); - }); - - it('calls onSubmit with name, email, password', () => { - const onSubmit = vi.fn(); - render(); - - fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } }); - fireEvent.change(screen.getByTestId('bl-register-email'), { - target: { value: 'alice@example.com' }, - }); - fireEvent.change(screen.getByTestId('bl-register-password'), { - target: { value: 'Password1!' }, - }); - fireEvent.change(screen.getByTestId('bl-register-confirm'), { - target: { value: 'Password1!' }, - }); - fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!); - - expect(onSubmit).toHaveBeenCalledWith({ - name: 'Alice', - email: 'alice@example.com', - password: 'Password1!', - }); - }); - - it('shows password mismatch error', () => { - render(); - fireEvent.change(screen.getByTestId('bl-register-password'), { - target: { value: 'Password1!' }, - }); - fireEvent.change(screen.getByTestId('bl-register-confirm'), { - target: { value: 'Different1!' }, - }); - - expect(screen.getByTestId('bl-register-mismatch')).toBeDefined(); - expect(screen.getByText('Passwords do not match')).toBeDefined(); - }); - - it('displays error message', () => { - render(); - expect(screen.getByTestId('bl-register-error')).toBeDefined(); - expect(screen.getByText('Email already taken')).toBeDefined(); - }); - - it('shows terms checkbox when termsUrl provided', () => { - render(); - expect(screen.getByTestId('bl-register-terms')).toBeDefined(); - expect(screen.getByText('Terms of Service')).toBeDefined(); - }); - - it('renders switch to login link', () => { - const onSwitch = vi.fn(); - render(); - const link = screen.getByTestId('bl-register-switch-login'); - fireEvent.click(link); - expect(onSwitch).toHaveBeenCalledOnce(); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Creating account...')).toBeDefined(); - }); - - it('shows password strength bar when typing', () => { - render(); - fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } }); - expect(screen.getByTestId('bl-password-strength')).toBeDefined(); - }); -}); - -describe('ForgotPasswordForm', () => { - beforeEach(() => cleanup()); - - it('renders email input and submit', () => { - render(); - expect(screen.getByTestId('bl-forgot-email')).toBeDefined(); - expect(screen.getByTestId('bl-forgot-submit')).toBeDefined(); - }); - - it('calls onSubmit with email', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-forgot-email'), { - target: { value: 'test@example.com' }, - }); - fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('test@example.com'); - }); - - it('displays error message', () => { - render(); - expect(screen.getByTestId('bl-forgot-error')).toBeDefined(); - }); - - it('displays success message', () => { - render(); - expect(screen.getByTestId('bl-forgot-success')).toBeDefined(); - expect(screen.getByText('Check your email')).toBeDefined(); - }); - - it('renders back button and calls onBack', () => { - const onBack = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-forgot-back')); - expect(onBack).toHaveBeenCalledOnce(); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Sending...')).toBeDefined(); - }); -}); - -describe('ResetPasswordForm', () => { - beforeEach(() => cleanup()); - - it('renders password fields and submit', () => { - render(); - expect(screen.getByTestId('bl-reset-password')).toBeDefined(); - expect(screen.getByTestId('bl-reset-confirm')).toBeDefined(); - expect(screen.getByTestId('bl-reset-submit')).toBeDefined(); - }); - - it('calls onSubmit with password', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); - fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } }); - fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('NewPass1!'); - }); - - it('shows mismatch error', () => { - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } }); - fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } }); - expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined(); - }); - - it('displays error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-reset-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-reset-success')).toBeDefined(); - }); - - it('shows password strength bar', () => { - render(); - fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } }); - expect(screen.getByTestId('bl-password-strength')).toBeDefined(); - }); -}); - -describe('VerifyEmailForm', () => { - beforeEach(() => cleanup()); - - it('renders code input and submit', () => { - render(); - expect(screen.getByTestId('bl-verify-code')).toBeDefined(); - expect(screen.getByTestId('bl-verify-submit')).toBeDefined(); - }); - - it('calls onSubmit with code', () => { - const onSubmit = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } }); - fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!); - expect(onSubmit).toHaveBeenCalledWith('123456'); - }); - - it('displays email address', () => { - render(); - expect(screen.getByText('test@example.com')).toBeDefined(); - }); - - it('renders resend button', () => { - const onResend = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-verify-resend')); - expect(onResend).toHaveBeenCalledOnce(); - }); - - it('displays error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-verify-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-verify-success')).toBeDefined(); - }); - - it('strips non-numeric characters', () => { - render(); - const input = screen.getByTestId('bl-verify-code'); - fireEvent.change(input, { target: { value: 'abc123def456' } }); - expect((input as unknown as { value: string }).value).toBe('123456'); - }); -}); - -describe('OnboardingShell', () => { - beforeEach(() => cleanup()); - - const steps = [ - { key: 'welcome', label: 'Welcome' }, - { key: 'profile', label: 'Profile' }, - { key: 'preferences', label: 'Preferences' }, - ]; - - it('renders steps and content', () => { - render( - -
Step 1 content
-
- ); - expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined(); - expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined(); - expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined(); - expect(screen.getByText('Step 1 content')).toBeDefined(); - expect(screen.getByText('Welcome')).toBeDefined(); - expect(screen.getByText('Profile')).toBeDefined(); - }); - - it('disables Back on first step', () => { - render( - -
- - ); - const back = screen.getByTestId('bl-onboarding-back'); - expect(back.getAttribute('disabled')).toBe(''); - }); - - it('calls onNext on middle step', () => { - const onNext = vi.fn(); - render( - -
- - ); - fireEvent.click(screen.getByTestId('bl-onboarding-next')); - expect(onNext).toHaveBeenCalledOnce(); - }); - - it('shows Complete on last step and calls onComplete', () => { - const onComplete = vi.fn(); - render( - -
- - ); - const btn = screen.getByTestId('bl-onboarding-complete'); - expect(btn.textContent).toBe('Complete'); - fireEvent.click(btn); - expect(onComplete).toHaveBeenCalledOnce(); - }); - - it('calls onBack on non-first step', () => { - const onBack = vi.fn(); - render( - -
- - ); - fireEvent.click(screen.getByTestId('bl-onboarding-back')); - expect(onBack).toHaveBeenCalledOnce(); - }); -}); - -describe('AuthPageLayout', () => { - beforeEach(() => cleanup()); - - it('renders product name and title', () => { - render( - -
Form content
-
- ); - expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp'); - expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In'); - expect(screen.getByText('Form content')).toBeDefined(); - }); - - it('renders subtitle when provided', () => { - render( - -
- - ); - expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back'); - }); - - it('renders logo as element', () => { - render( - Logo} - > -
- - ); - expect(screen.getByTestId('custom-logo')).toBeDefined(); - }); - - it('renders footer', () => { - render( - Footer text}> -
- - ); - expect(screen.getByTestId('bl-auth-footer')).toBeDefined(); - expect(screen.getByText('Footer text')).toBeDefined(); - }); -}); - -describe('PasswordStrengthBar', () => { - beforeEach(() => cleanup()); - - it('returns null for empty password', () => { - const { container } = render(); - expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull(); - }); - - it('shows Weak for short password', () => { - render(); - expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak'); - }); - - it('shows Strong for complex password', () => { - render(); - expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong'); - }); -}); - -describe('getPasswordStrength', () => { - it('returns weak for very short passwords', () => { - expect(getPasswordStrength('ab')).toBe('weak'); - }); - - it('returns fair for medium passwords', () => { - expect(getPasswordStrength('abcdefgh1')).toBe('fair'); - }); - - it('returns good for decent passwords', () => { - expect(getPasswordStrength('Abcdefgh1')).toBe('good'); - }); - - it('returns strong for complex passwords', () => { - expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong'); - }); -}); diff --git a/vendor/bytelyst/auth-ui/src/index.ts b/vendor/bytelyst/auth-ui/src/index.ts deleted file mode 100644 index e24f874..0000000 --- a/vendor/bytelyst/auth-ui/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { LoginForm } from './LoginForm.js'; -export { RegisterForm } from './RegisterForm.js'; -export { ForgotPasswordForm } from './ForgotPasswordForm.js'; -export { ResetPasswordForm } from './ResetPasswordForm.js'; -export { VerifyEmailForm } from './VerifyEmailForm.js'; -export { MfaChallenge } from './MfaChallenge.js'; -export { SocialButtons } from './SocialButtons.js'; -export { OnboardingShell } from './OnboardingShell.js'; -export { AuthPageLayout } from './AuthPageLayout.js'; -export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js'; -export type { - LoginFormProps, - RegisterFormProps, - ForgotPasswordFormProps, - ResetPasswordFormProps, - VerifyEmailFormProps, - MfaChallengeProps, - SocialButtonsProps, - SocialProvider, - OnboardingShellProps, - OnboardingStep, - AuthPageLayoutProps, - PasswordStrength, -} from './types.js'; diff --git a/vendor/bytelyst/auth-ui/src/types.ts b/vendor/bytelyst/auth-ui/src/types.ts deleted file mode 100644 index 706f44f..0000000 --- a/vendor/bytelyst/auth-ui/src/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -export type SocialProvider = 'google' | 'microsoft' | 'apple'; - -export interface LoginFormProps { - /** Called when user submits email/password. */ - onSubmit: (email: string, password: string) => void; - /** Social providers to display. */ - providers?: SocialProvider[]; - /** Called when user clicks a social login button. */ - onSocialLogin?: (provider: SocialProvider) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface MfaChallengeProps { - /** Called when user submits the MFA code. */ - onSubmit: (code: string) => void; - /** Called when user clicks "Use recovery code". */ - onUseRecovery?: () => void; - /** MFA methods available. */ - methods?: string[]; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface SocialButtonsProps { - /** Providers to display buttons for. */ - providers: SocialProvider[]; - /** Called when a provider button is clicked. */ - onSelect: (provider: SocialProvider) => void; - /** Whether the buttons are disabled. */ - disabled?: boolean; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface RegisterFormProps { - /** Called when user submits registration. */ - onSubmit: (data: { name: string; email: string; password: string }) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Terms of service URL (renders checkbox if provided). */ - termsUrl?: string; - /** Privacy policy URL. */ - privacyUrl?: string; - /** Called when user clicks "Already have an account?" */ - onSwitchToLogin?: () => void; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface ForgotPasswordFormProps { - /** Called when user submits email for password reset. */ - onSubmit: (email: string) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Check your email"). */ - success?: string | null; - /** Called when user clicks "Back to login". */ - onBack?: () => void; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface ResetPasswordFormProps { - /** Called when user submits new password. */ - onSubmit: (password: string) => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Password updated"). */ - success?: string | null; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface VerifyEmailFormProps { - /** Called when user submits the verification code. */ - onSubmit: (code: string) => void; - /** Called when user clicks "Resend code". */ - onResend?: () => void; - /** Whether the form is currently loading. */ - isLoading?: boolean; - /** Error message to display. */ - error?: string | null; - /** Success message (e.g., "Code resent"). */ - success?: string | null; - /** Email address being verified (for display). */ - email?: string; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface OnboardingStep { - /** Unique key for the step. */ - key: string; - /** Display label for the step indicator. */ - label: string; -} - -export interface OnboardingShellProps { - /** Ordered list of steps. */ - steps: OnboardingStep[]; - /** Index of the current step (0-based). */ - currentStep: number; - /** Called when user clicks Next. */ - onNext: () => void; - /** Called when user clicks Back. */ - onBack: () => void; - /** Called when the final step completes. */ - onComplete: () => void; - /** Content to render for the current step. */ - children: React.ReactNode; - /** Additional CSS class for the root element. */ - className?: string; -} - -export interface AuthPageLayoutProps { - /** Product name displayed at the top. */ - productName: string; - /** Optional logo element or URL. */ - logo?: React.ReactNode; - /** Page title (e.g., "Sign In", "Create Account"). */ - title: string; - /** Subtitle or description. */ - subtitle?: string; - /** Form content. */ - children: React.ReactNode; - /** Footer content (links, etc.). */ - footer?: React.ReactNode; - /** Additional CSS class for the root element. */ - className?: string; -} - -export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong'; diff --git a/vendor/bytelyst/auth-ui/tsconfig.json b/vendor/bytelyst/auth-ui/tsconfig.json deleted file mode 100644 index 4447784..0000000 --- a/vendor/bytelyst/auth-ui/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/vendor/bytelyst/auth-ui/vitest.config.ts b/vendor/bytelyst/auth-ui/vitest.config.ts deleted file mode 100644 index cf32686..0000000 --- a/vendor/bytelyst/auth-ui/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/auth/package.json b/vendor/bytelyst/auth/package.json deleted file mode 100644 index 86164e6..0000000 --- a/vendor/bytelyst/auth/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/auth", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "jose": ">=5.0.0", - "bcryptjs": ">=2.4.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/auth/src/__tests__/auth.test.ts b/vendor/bytelyst/auth/src/__tests__/auth.test.ts deleted file mode 100644 index fbb7384..0000000 --- a/vendor/bytelyst/auth/src/__tests__/auth.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { createJwtUtils, hashPassword, verifyPassword } from '../index.js'; - -describe('JWT utilities', () => { - const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; - - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('creates and verifies an access token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const token = await jwt.createAccessToken({ - sub: 'user-1', - email: 'test@example.com', - role: 'admin', - }); - - expect(typeof token).toBe('string'); - expect(token.split('.')).toHaveLength(3); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.email).toBe('test@example.com'); - expect(payload!.role).toBe('admin'); - expect(payload!.type).toBe('access'); - }); - - it('creates and verifies a refresh token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const token = await jwt.createRefreshToken({ sub: 'user-1' }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.type).toBe('refresh'); - }); - - it('returns null for invalid token', async () => { - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - const result = await jwt.verifyToken('garbage.not.valid'); - expect(result).toBeNull(); - }); - - it('returns null for wrong issuer', async () => { - const jwt1 = createJwtUtils({ issuer: 'issuer-a' }); - const jwt2 = createJwtUtils({ issuer: 'issuer-b' }); - - const token = await jwt1.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - const result = await jwt2.verifyToken(token); - expect(result).toBeNull(); - }); - - it('sets productId from payload or defaults to issuer', async () => { - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - - const t1 = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - const p1 = await jwt.verifyToken(t1); - expect(p1!.productId).toBe('lysnrai'); - - const t2 = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - productId: 'mindlyst', - }); - const p2 = await jwt.verifyToken(t2); - expect(p2!.productId).toBe('mindlyst'); - }); - - it('respects custom expiry', async () => { - const jwt = createJwtUtils({ - issuer: 'test', - accessTokenExpiry: '2h', - refreshTokenExpiry: '7d', - }); - - const access = await jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - const refresh = await jwt.createRefreshToken({ sub: 'u1' }); - - expect(typeof access).toBe('string'); - expect(typeof refresh).toBe('string'); - }); - - it('throws when JWT_SECRET is not set', async () => { - const origSecret = process.env.JWT_SECRET; - delete process.env.JWT_SECRET; - - const jwt = createJwtUtils({ issuer: 'test' }); - await expect( - jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) - ).rejects.toThrow('JWT_SECRET must be set'); - - process.env.JWT_SECRET = origSecret; - }); -}); - -describe('password hashing', () => { - it('hashes a password and verifies it', async () => { - const hash = await hashPassword('MySecret123!'); - expect(typeof hash).toBe('string'); - expect(hash).not.toBe('MySecret123!'); - - const valid = await verifyPassword('MySecret123!', hash); - expect(valid).toBe(true); - }); - - it('rejects wrong password', async () => { - const hash = await hashPassword('correct-password'); - const valid = await verifyPassword('wrong-password', hash); - expect(valid).toBe(false); - }); - - it('produces different hashes for same input', async () => { - const h1 = await hashPassword('same'); - const h2 = await hashPassword('same'); - expect(h1).not.toBe(h2); // different salts - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts b/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts deleted file mode 100644 index 3d35bbc..0000000 --- a/vendor/bytelyst/auth/src/__tests__/e2e-auth-flow.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * End-to-end auth flow test: create token → extract auth → require role. - * Exercises the full JWT lifecycle without network calls. - */ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { - createJwtUtils, - extractAuth, - requireRole, - hashPassword, - verifyPassword, -} from '../index.js'; - -const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!'; - -describe('E2E auth flow', () => { - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('full flow: login credentials → JWT → authenticated request → role check', async () => { - // 1. Simulate password verification (login step) - const storedHash = await hashPassword('SecretPass123!'); - const passwordValid = await verifyPassword('SecretPass123!', storedHash); - expect(passwordValid).toBe(true); - - // 2. Issue access token (platform-service would do this) - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - const accessToken = await jwt.createAccessToken({ - sub: 'user-admin-001', - email: 'admin@lysnrai.com', - role: 'super_admin', - productId: 'lysnrai', - }); - expect(typeof accessToken).toBe('string'); - - // 3. Simulate authenticated request (any service receives this) - const req = { headers: { authorization: `Bearer ${accessToken}` } }; - const auth = await extractAuth(req); - expect(auth.sub).toBe('user-admin-001'); - expect(auth.email).toBe('admin@lysnrai.com'); - expect(auth.role).toBe('super_admin'); - expect(auth.productId).toBe('lysnrai'); - - // 4. Role-gated endpoint check - const adminAuth = await requireRole(req, 'super_admin', 'admin'); - expect(adminAuth.sub).toBe('user-admin-001'); - - // 5. Role rejection for wrong role - await expect(requireRole(req, 'viewer')).rejects.toMatchObject({ - statusCode: 403, - }); - }); - - it('refresh token cannot be used for authenticated requests', async () => { - const jwt = createJwtUtils({ issuer: 'lysnrai' }); - const refreshToken = await jwt.createRefreshToken({ - sub: 'user-001', - productId: 'lysnrai', - }); - - const req = { headers: { authorization: `Bearer ${refreshToken}` } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => { - // extractAuth only checks type=access via jwtVerify without issuer - // But verifyToken checks issuer — this is the cross-service security model - const jwtA = createJwtUtils({ issuer: 'lysnrai' }); - const jwtB = createJwtUtils({ issuer: 'mindlyst' }); - - const tokenA = await jwtA.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - // verifyToken with wrong issuer rejects - const resultB = await jwtB.verifyToken(tokenA); - expect(resultB).toBeNull(); - - // verifyToken with correct issuer passes - const resultA = await jwtA.verifyToken(tokenA); - expect(resultA).not.toBeNull(); - expect(resultA!.sub).toBe('u1'); - }); - - it('wrong password fails login flow before token issuance', async () => { - const storedHash = await hashPassword('CorrectPassword'); - const passwordValid = await verifyPassword('WrongPassword', storedHash); - expect(passwordValid).toBe(false); - // No token should be issued — the flow stops here - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/middleware.test.ts b/vendor/bytelyst/auth/src/__tests__/middleware.test.ts deleted file mode 100644 index fb498d3..0000000 --- a/vendor/bytelyst/auth/src/__tests__/middleware.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { createJwtUtils, extractAuth, requireRole } from '../index.js'; - -const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; -let validAccessToken: string; -let refreshToken: string; - -describe('extractAuth', () => { - beforeAll(async () => { - process.env.JWT_SECRET = SECRET; - const jwt = createJwtUtils({ issuer: 'test-issuer' }); - validAccessToken = await jwt.createAccessToken({ - sub: 'user-1', - email: 'test@example.com', - role: 'admin', - productId: 'lysnrai', - }); - refreshToken = await jwt.createRefreshToken({ sub: 'user-1' }); - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('extracts auth from valid Bearer token', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await extractAuth(req); - expect(payload.sub).toBe('user-1'); - expect(payload.email).toBe('test@example.com'); - expect(payload.role).toBe('admin'); - expect(payload.productId).toBe('lysnrai'); - expect(payload.type).toBe('access'); - }); - - it('throws 401 when no authorization header', async () => { - const req = { headers: {} }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Unauthorized', - }); - }); - - it('throws 401 when authorization header is not Bearer', async () => { - const req = { headers: { authorization: 'Basic abc123' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Unauthorized', - }); - }); - - it('throws 401 for invalid token', async () => { - const req = { headers: { authorization: 'Bearer garbage.not.valid' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('throws 401 for refresh token (requires access type)', async () => { - const req = { headers: { authorization: `Bearer ${refreshToken}` } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - message: 'Invalid or expired token', - }); - }); - - it('throws 401 for empty Bearer value', async () => { - const req = { headers: { authorization: 'Bearer ' } }; - await expect(extractAuth(req)).rejects.toMatchObject({ - statusCode: 401, - }); - }); -}); - -describe('requireRole', () => { - beforeAll(() => { - process.env.JWT_SECRET = SECRET; - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('passes when role matches', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req, 'admin'); - expect(payload.sub).toBe('user-1'); - expect(payload.role).toBe('admin'); - }); - - it('passes when role is in allowed list', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req, 'viewer', 'admin', 'super_admin'); - expect(payload.role).toBe('admin'); - }); - - it('throws 403 when role does not match', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({ - statusCode: 403, - message: 'Insufficient permissions', - }); - }); - - it('passes with no roles specified (any authenticated user)', async () => { - const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; - const payload = await requireRole(req); - expect(payload.sub).toBe('user-1'); - }); - - it('throws 401 when no auth header (before checking role)', async () => { - const req = { headers: {} }; - await expect(requireRole(req, 'admin')).rejects.toMatchObject({ - statusCode: 401, - }); - }); -}); diff --git a/vendor/bytelyst/auth/src/__tests__/rs256.test.ts b/vendor/bytelyst/auth/src/__tests__/rs256.test.ts deleted file mode 100644 index a96832c..0000000 --- a/vendor/bytelyst/auth/src/__tests__/rs256.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it, beforeAll, afterAll } from 'vitest'; -import { generateKeyPair } from 'jose'; -import { createJwtUtils } from '../index.js'; - -describe('JWT RS256 support (Phase 4C)', () => { - const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; - let rsaPrivateKey: string; - let rsaPublicKey: string; - - beforeAll(async () => { - process.env.JWT_SECRET = SECRET; - - // Generate RSA key pair for testing (extractable required for PEM export in jose v6) - const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); - const { exportPKCS8, exportSPKI } = await import('jose'); - rsaPrivateKey = await exportPKCS8(privateKey); - rsaPublicKey = await exportSPKI(publicKey); - }); - - afterAll(() => { - delete process.env.JWT_SECRET; - }); - - it('signs and verifies tokens with RS256', async () => { - const jwt = createJwtUtils({ - issuer: 'test-rs256', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await jwt.createAccessToken({ - sub: 'user-1', - email: 'rs256@test.com', - role: 'admin', - }); - - expect(typeof token).toBe('string'); - expect(token.split('.')).toHaveLength(3); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.email).toBe('rs256@test.com'); - expect(payload!.type).toBe('access'); - }); - - it('dual verify: RS256 verifier can fall back to HS256 tokens', async () => { - // Create an HS256 token (simulates old tokens during migration) - const hs256Jwt = createJwtUtils({ issuer: 'dual-test' }); - const hs256Token = await hs256Jwt.createAccessToken({ - sub: 'u-old', - email: 'old@test.com', - role: 'user', - }); - - // Verify with RS256-configured jwt (should fall back to HS256) - const dualJwt = createJwtUtils({ - issuer: 'dual-test', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const payload = await dualJwt.verifyToken(hs256Token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('u-old'); - expect(payload!.email).toBe('old@test.com'); - }); - - it('RS256 token is NOT verified by HS256-only verifier with different issuer', async () => { - const rs256Jwt = createJwtUtils({ - issuer: 'rs256-only', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await rs256Jwt.createAccessToken({ - sub: 'u1', - email: 'a@b.com', - role: 'user', - }); - - // HS256-only verifier with different issuer should reject - const hs256Jwt = createJwtUtils({ issuer: 'different-issuer' }); - const result = await hs256Jwt.verifyToken(token); - expect(result).toBeNull(); - }); - - it('RS256 refresh token works', async () => { - const jwt = createJwtUtils({ - issuer: 'test-rs256', - algorithm: 'RS256', - rsaPrivateKey, - rsaPublicKey, - }); - - const token = await jwt.createRefreshToken({ sub: 'user-1' }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('user-1'); - expect(payload!.type).toBe('refresh'); - }); - - it('throws when RS256 signing without private key', async () => { - const jwt = createJwtUtils({ - issuer: 'test-no-key', - algorithm: 'RS256', - rsaPublicKey, // public only, no private - }); - - await expect( - jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' }) - ).rejects.toThrow('rsaPrivateKey is required'); - }); - - it('HS256 still works as default (backward compatible)', async () => { - const jwt = createJwtUtils({ issuer: 'hs256-compat' }); - - const token = await jwt.createAccessToken({ - sub: 'u1', - email: 'compat@test.com', - role: 'user', - }); - - const payload = await jwt.verifyToken(token); - expect(payload).not.toBeNull(); - expect(payload!.sub).toBe('u1'); - expect(payload!.email).toBe('compat@test.com'); - }); -}); diff --git a/vendor/bytelyst/auth/src/index.ts b/vendor/bytelyst/auth/src/index.ts deleted file mode 100644 index f00bf95..0000000 --- a/vendor/bytelyst/auth/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { createJwtUtils } from './jwt.js'; -export { extractAuth, requireRole } from './middleware.js'; -export { hashPassword, verifyPassword } from './password.js'; -export { getCurrentUser } from './server-auth.js'; -export type { TokenPayload, AuthPayload, JwtUtilsOptions, JwtUtils } from './types.js'; diff --git a/vendor/bytelyst/auth/src/jwt.ts b/vendor/bytelyst/auth/src/jwt.ts deleted file mode 100644 index 57f8bb1..0000000 --- a/vendor/bytelyst/auth/src/jwt.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * JWT utilities — configurable issuer, expiry, and algorithm. - * Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose. - * - * RS256 mode (Phase 4C SmartAuth): - * - Sign with RSA private key (PEM) - * - Verify with RSA public key (PEM) or remote JWKS URL - * - Dual verification: tries RS256 first, falls back to HS256 during migration - */ - -import { - SignJWT, - jwtVerify, - importPKCS8, - importSPKI, - createRemoteJWKSet, - type CryptoKey as JoseCryptoKey, -} from 'jose'; -import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js'; - -function getHmacSecret(): Uint8Array { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET must be set'); - return new TextEncoder().encode(secret); -} - -/** - * Create a JWT utility set with the given issuer and expiry configuration. - * - * @example - * ```ts - * // HS256 (default, backward-compatible) - * const jwt = createJwtUtils({ issuer: "bytelyst-platform" }); - * - * // RS256 (SmartAuth Phase 4C) - * const jwt = createJwtUtils({ - * issuer: "bytelyst-platform", - * algorithm: "RS256", - * rsaPrivateKey: process.env.JWT_PRIVATE_KEY, - * rsaPublicKey: process.env.JWT_PUBLIC_KEY, - * }); - * - * // RS256 verify-only (product backends — no private key) - * const jwt = createJwtUtils({ - * issuer: "bytelyst-platform", - * algorithm: "RS256", - * jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json", - * }); - * ``` - */ -export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { - const { - issuer, - accessTokenExpiry = '1h', - refreshTokenExpiry = '30d', - algorithm = 'HS256', - rsaPrivateKey, - rsaPublicKey, - jwksUrl, - } = options; - - // ── Key caches ──────────────────────────────────── - - let _rsaPrivateKeyObj: JoseCryptoKey | null = null; - let _rsaPublicKeyObj: JoseCryptoKey | null = null; - let _jwksKeySet: ReturnType | null = null; - - async function getRsaPrivateKey(): Promise { - if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj; - if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing'); - _rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey; - return _rsaPrivateKeyObj; - } - - async function getRsaPublicKey(): Promise { - if (_rsaPublicKeyObj) return _rsaPublicKeyObj; - if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification'); - _rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey; - return _rsaPublicKeyObj; - } - - function getJwksKeySet(): ReturnType { - if (_jwksKeySet) return _jwksKeySet; - if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification'); - _jwksKeySet = createRemoteJWKSet(new URL(jwksUrl)); - return _jwksKeySet; - } - - // ── Signing ─────────────────────────────────────── - - async function sign(claims: Record, expiry: string): Promise { - if (algorithm === 'RS256') { - const key = await getRsaPrivateKey(); - return new SignJWT(claims) - .setProtectedHeader({ alg: 'RS256' }) - .setIssuedAt() - .setExpirationTime(expiry) - .setIssuer(issuer) - .sign(key); - } - return new SignJWT(claims) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(expiry) - .setIssuer(issuer) - .sign(getHmacSecret()); - } - - // ── Verification (dual: RS256 first, HS256 fallback) ── - - async function verifyWithRS256(token: string): Promise { - try { - if (jwksUrl) { - const keySet = getJwksKeySet(); - const { payload } = await jwtVerify(token, keySet, { issuer }); - return payload as unknown as TokenPayload; - } - if (rsaPublicKey) { - const key = await getRsaPublicKey(); - const { payload } = await jwtVerify(token, key, { issuer }); - return payload as unknown as TokenPayload; - } - return null; - } catch { - return null; - } - } - - async function verifyWithHS256(token: string): Promise { - try { - const secret = getHmacSecret(); - const { payload } = await jwtVerify(token, secret, { issuer }); - return payload as unknown as TokenPayload; - } catch { - return null; - } - } - - return { - async createAccessToken(payload) { - return sign( - { - ...payload, - productId: payload.productId || issuer, - type: 'access', - }, - accessTokenExpiry - ); - }, - - async createRefreshToken(payload) { - return sign( - { - sub: payload.sub, - productId: payload.productId || issuer, - type: 'refresh', - }, - refreshTokenExpiry - ); - }, - - async verifyToken(token: string) { - // Dual verification: try RS256 first (if configured), then HS256 fallback - if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) { - const rs256Result = await verifyWithRS256(token); - if (rs256Result) return rs256Result; - } - // HS256 fallback (safe during migration; removed after full RS256 rollout) - try { - return await verifyWithHS256(token); - } catch { - return null; - } - }, - }; -} diff --git a/vendor/bytelyst/auth/src/middleware.ts b/vendor/bytelyst/auth/src/middleware.ts deleted file mode 100644 index 3cbdf5f..0000000 --- a/vendor/bytelyst/auth/src/middleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Fastify auth middleware — validates JWT tokens from Authorization headers. - */ - -import { jwtVerify } from 'jose'; -import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; -import type { AuthPayload } from './types.js'; - -function getSecret(): Uint8Array { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET must be set'); - return new TextEncoder().encode(secret); -} - -/** - * Extract and verify auth payload from an Authorization header. - * Works with any request-like object that has headers.authorization. - * - * @throws Error with message "Unauthorized" if no valid Bearer token - * @throws Error with message "Invalid or expired token" if verification fails - */ -export async function extractAuth(req: { - headers: { authorization?: string }; -}): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) { - throw new UnauthorizedError(); - } - const token = auth.slice(7); - try { - const { payload } = await jwtVerify(token, getSecret()); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } -} - -/** - * Require specific roles. Extracts auth first, then checks role. - * - * @throws Error with statusCode 403 if role doesn't match - */ -export async function requireRole( - req: { headers: { authorization?: string } }, - ...roles: string[] -): Promise { - const payload = await extractAuth(req); - if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw new ForbiddenError('Insufficient permissions'); - } - return payload; -} diff --git a/vendor/bytelyst/auth/src/password.ts b/vendor/bytelyst/auth/src/password.ts deleted file mode 100644 index 296a885..0000000 --- a/vendor/bytelyst/auth/src/password.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Password hashing utilities using bcryptjs. - */ - -import bcrypt from 'bcryptjs'; - -const SALT_ROUNDS = 12; - -export async function hashPassword(plain: string): Promise { - return bcrypt.hash(plain, SALT_ROUNDS); -} - -export async function verifyPassword(plain: string, hash: string): Promise { - return bcrypt.compare(plain, hash); -} diff --git a/vendor/bytelyst/auth/src/server-auth.ts b/vendor/bytelyst/auth/src/server-auth.ts deleted file mode 100644 index 1c4668a..0000000 --- a/vendor/bytelyst/auth/src/server-auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Server-side auth helpers for Next.js API routes. - */ - -import type { TokenPayload } from './types.js'; - -/** - * Get the current user from an Authorization header value. - * Pairs with a verifyToken function and a getUserById function. - * - * @param authHeader - The Authorization header value (e.g., "Bearer xxx") - * @param verifyToken - Function to verify the JWT and return a payload - * @param getUserById - Function to look up the user by their ID - * @returns The user object or null if auth fails - */ -export async function getCurrentUser( - authHeader: string | null, - verifyToken: (token: string) => Promise, - getUserById: (id: string) => Promise -): Promise { - if (!authHeader?.startsWith('Bearer ')) return null; - const token = authHeader.slice(7); - const payload = await verifyToken(token); - if (!payload || payload.type !== 'access') return null; - return getUserById(payload.sub); -} diff --git a/vendor/bytelyst/auth/src/types.ts b/vendor/bytelyst/auth/src/types.ts deleted file mode 100644 index 6455fbe..0000000 --- a/vendor/bytelyst/auth/src/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -export interface TokenPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: 'access' | 'refresh'; - [key: string]: unknown; -} - -export interface AuthPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; -} - -export interface JwtUtilsOptions { - issuer: string; - accessTokenExpiry?: string; - refreshTokenExpiry?: string; - /** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */ - algorithm?: 'HS256' | 'RS256'; - /** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */ - rsaPrivateKey?: string; - /** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */ - rsaPublicKey?: string; - /** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */ - jwksUrl?: string; -} - -export interface JwtUtils { - createAccessToken(payload: { - sub: string; - email: string; - role: string; - productId?: string; - }): Promise; - createRefreshToken(payload: { sub: string; productId?: string }): Promise; - verifyToken(token: string): Promise; -} diff --git a/vendor/bytelyst/auth/tsconfig.json b/vendor/bytelyst/auth/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/auth/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/auth/vitest.config.ts b/vendor/bytelyst/auth/vitest.config.ts deleted file mode 100644 index 03ac558..0000000 --- a/vendor/bytelyst/auth/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - pool: 'forks', - testTimeout: 15_000, - }, -}); diff --git a/vendor/bytelyst/backend-config/package.json b/vendor/bytelyst/backend-config/package.json deleted file mode 100644 index fe64461..0000000 --- a/vendor/bytelyst/backend-config/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/backend-config", - "version": "0.1.5", - "description": "Shared Zod config schema base for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "dependencies": { - "zod": "^3.24.2" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-config/src/index.test.ts b/vendor/bytelyst/backend-config/src/index.test.ts deleted file mode 100644 index 61ab6b5..0000000 --- a/vendor/bytelyst/backend-config/src/index.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { baseBackendConfigSchema, parseBackendConfig } from './index.js'; - -describe('baseBackendConfigSchema', () => { - it('parses minimal valid env', () => { - const config = baseBackendConfigSchema.parse({ - JWT_SECRET: 'test-secret', - }); - expect(config.PORT).toBe(3000); - expect(config.HOST).toBe('0.0.0.0'); - expect(config.NODE_ENV).toBe('development'); - expect(config.DB_PROVIDER).toBe('cosmos'); - expect(config.COSMOS_DATABASE).toBe('lysnrai'); - expect(config.JWT_SECRET).toBe('test-secret'); - expect(config.PLATFORM_JWKS_URL).toBeUndefined(); - }); - - it('applies overrides', () => { - const config = baseBackendConfigSchema.parse({ - PORT: '4010', - NODE_ENV: 'production', - DB_PROVIDER: 'memory', - JWT_SECRET: 'prod-secret', - PLATFORM_JWKS_URL: 'https://example.com/.well-known/jwks.json', - }); - expect(config.PORT).toBe(4010); - expect(config.NODE_ENV).toBe('production'); - expect(config.DB_PROVIDER).toBe('memory'); - expect(config.PLATFORM_JWKS_URL).toBe('https://example.com/.well-known/jwks.json'); - }); - - it('rejects missing JWT_SECRET', () => { - expect(() => baseBackendConfigSchema.parse({})).toThrow(); - }); - - it('rejects invalid NODE_ENV', () => { - expect(() => baseBackendConfigSchema.parse({ JWT_SECRET: 's', NODE_ENV: 'staging' })).toThrow(); - }); - - it('rejects invalid DB_PROVIDER', () => { - expect(() => - baseBackendConfigSchema.parse({ JWT_SECRET: 's', DB_PROVIDER: 'postgres' }) - ).toThrow(); - }); -}); - -describe('baseBackendConfigSchema.extend()', () => { - const extendedSchema = baseBackendConfigSchema.extend({ - PLATFORM_SERVICE_URL: baseBackendConfigSchema.shape.HOST.default('http://localhost:4003'), - CUSTOM_FLAG: baseBackendConfigSchema.shape.NODE_ENV.optional(), - }); - - it('parses extended config with product-specific fields', () => { - const config = extendedSchema.parse({ - JWT_SECRET: 'test-secret', - PORT: '4018', - SERVICE_NAME: 'actiontrail-backend', - }); - expect(config.PORT).toBe(4018); - expect(config.SERVICE_NAME).toBe('actiontrail-backend'); - expect(config.PLATFORM_SERVICE_URL).toBe('http://localhost:4003'); - }); -}); - -describe('parseBackendConfig', () => { - it('parses from explicit env object', () => { - const config = parseBackendConfig(baseBackendConfigSchema, { - JWT_SECRET: 'from-env', - PORT: '9999', - }); - expect(config.JWT_SECRET).toBe('from-env'); - expect(config.PORT).toBe(9999); - }); - - it('works with extended schemas', () => { - const schema = baseBackendConfigSchema.extend({ - WEBHOOK_SECRET: baseBackendConfigSchema.shape.HOST.default('dev-webhook'), - }); - const config = parseBackendConfig(schema, { JWT_SECRET: 'x' }); - expect(config.WEBHOOK_SECRET).toBe('dev-webhook'); - }); -}); diff --git a/vendor/bytelyst/backend-config/src/index.ts b/vendor/bytelyst/backend-config/src/index.ts deleted file mode 100644 index ae8011c..0000000 --- a/vendor/bytelyst/backend-config/src/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod'; - -/** - * Base Zod schema shared by all product backends. - * - * Products extend this with `.extend({...})` to add product-specific fields. - * The base covers: server, CORS, Cosmos DB, JWT auth, DB provider. - */ -export const baseBackendConfigSchema = z.object({ - PORT: z.coerce.number().default(3000), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string().default('backend'), - - DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), - COSMOS_ENDPOINT: z.string().default(''), - COSMOS_KEY: z.string().default(''), - COSMOS_DATABASE: z.string().default('lysnrai'), - - JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), - PLATFORM_JWKS_URL: z.string().url().optional(), -}); - -export type BaseBackendConfig = z.infer; - -/** - * Parse and validate backend config from process.env. - * - * @param schema — Zod object schema (typically `baseBackendConfigSchema.extend({...})`) - * @param env — environment object (defaults to process.env) - * @returns Validated, typed config - */ -export function parseBackendConfig( - schema: z.ZodObject, - env: Record = process.env as Record -): z.infer> { - return schema.parse(env); -} diff --git a/vendor/bytelyst/backend-config/tsconfig.json b/vendor/bytelyst/backend-config/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-config/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/backend-flags/package.json b/vendor/bytelyst/backend-flags/package.json deleted file mode 100644 index 96f1aff..0000000 --- a/vendor/bytelyst/backend-flags/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/backend-flags", - "version": "0.1.5", - "description": "In-memory feature flag registry for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-flags/src/index.test.ts b/vendor/bytelyst/backend-flags/src/index.test.ts deleted file mode 100644 index fa5aba5..0000000 --- a/vendor/bytelyst/backend-flags/src/index.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createFlagRegistry } from './index.js'; - -describe('createFlagRegistry', () => { - it('returns default flag values', () => { - const registry = createFlagRegistry({ - defaults: { 'feature.a': true, 'feature.b': false }, - }); - expect(registry.isFeatureEnabled('feature.a')).toBe(true); - expect(registry.isFeatureEnabled('feature.b')).toBe(false); - }); - - it('returns false for unknown flags', () => { - const registry = createFlagRegistry({ defaults: {} }); - expect(registry.isFeatureEnabled('nonexistent')).toBe(false); - }); - - it('getAllFlags returns all defaults', () => { - const registry = createFlagRegistry({ - defaults: { a: true, b: false, c: true }, - }); - expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true }); - }); - - it('setFlag overrides a value', () => { - const registry = createFlagRegistry({ defaults: { x: false } }); - expect(registry.isFeatureEnabled('x')).toBe(false); - registry.setFlag('x', true); - expect(registry.isFeatureEnabled('x')).toBe(true); - }); - - it('setFlag creates new flags', () => { - const registry = createFlagRegistry({ defaults: {} }); - registry.setFlag('new.flag', true); - expect(registry.isFeatureEnabled('new.flag')).toBe(true); - expect(registry.getAllFlags()).toEqual({ 'new.flag': true }); - }); - - it('accepts userId parameter without error', () => { - const registry = createFlagRegistry({ defaults: { a: true } }); - expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true); - }); -}); diff --git a/vendor/bytelyst/backend-flags/src/index.ts b/vendor/bytelyst/backend-flags/src/index.ts deleted file mode 100644 index 30ad476..0000000 --- a/vendor/bytelyst/backend-flags/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * In-memory feature flag registry for product backends. - * - * Products call createFlagRegistry() with their default flags, - * then use isFeatureEnabled/getAllFlags/setFlag as needed. - */ - -export interface FlagRegistry { - isFeatureEnabled(flag: string, userId?: string): boolean; - getAllFlags(): Record; - setFlag(flag: string, value: boolean): void; -} - -export interface FlagRegistryOptions { - /** Default flag values. */ - defaults: Record; - /** Master switch — when false, flags are still resolved from defaults but - * the registry won't attempt remote/dynamic flag resolution (future use). */ - enabled?: boolean; -} - -export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry { - const flags: Map = new Map(Object.entries(opts.defaults)); - - return { - isFeatureEnabled(flag: string, _userId?: string): boolean { - return flags.get(flag) ?? false; - }, - - getAllFlags(): Record { - return Object.fromEntries(flags); - }, - - setFlag(flag: string, value: boolean): void { - flags.set(flag, value); - }, - }; -} diff --git a/vendor/bytelyst/backend-flags/tsconfig.json b/vendor/bytelyst/backend-flags/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-flags/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/backend-telemetry/package.json b/vendor/bytelyst/backend-telemetry/package.json deleted file mode 100644 index 64e2400..0000000 --- a/vendor/bytelyst/backend-telemetry/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/backend-telemetry", - "version": "0.1.5", - "description": "In-memory telemetry event buffer for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/backend-telemetry/src/index.test.ts b/vendor/bytelyst/backend-telemetry/src/index.test.ts deleted file mode 100644 index ad15d9e..0000000 --- a/vendor/bytelyst/backend-telemetry/src/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createTelemetryBuffer } from './index.js'; - -describe('createTelemetryBuffer', () => { - it('buffers events when enabled', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('test.event', 'user-1', { key: 'val' }); - const events = buf.getBufferedEvents(); - expect(events).toHaveLength(1); - expect(events[0].event).toBe('test.event'); - expect(events[0].userId).toBe('user-1'); - expect(events[0].properties).toEqual({ key: 'val' }); - expect(events[0].timestamp).toBeDefined(); - }); - - it('is a no-op when disabled', () => { - const buf = createTelemetryBuffer({ enabled: false }); - buf.trackEvent('test.event', 'user-1'); - expect(buf.getBufferedEvents()).toHaveLength(0); - }); - - it('flushEvents returns and clears buffer', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('a'); - buf.trackEvent('b'); - const flushed = buf.flushEvents(); - expect(flushed).toHaveLength(2); - expect(buf.getBufferedEvents()).toHaveLength(0); - }); - - it('getBufferedEvents returns a copy', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('a'); - const copy = buf.getBufferedEvents(); - copy.push({ event: 'fake' }); - expect(buf.getBufferedEvents()).toHaveLength(1); - }); - - it('handles missing optional fields', () => { - const buf = createTelemetryBuffer({ enabled: true }); - buf.trackEvent('minimal'); - const events = buf.getBufferedEvents(); - expect(events[0].userId).toBeUndefined(); - expect(events[0].properties).toBeUndefined(); - }); -}); diff --git a/vendor/bytelyst/backend-telemetry/src/index.ts b/vendor/bytelyst/backend-telemetry/src/index.ts deleted file mode 100644 index ffd9cfa..0000000 --- a/vendor/bytelyst/backend-telemetry/src/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * In-memory telemetry event buffer for product backends. - * - * Products call createTelemetryBuffer() with an enabled flag, - * then use trackEvent/getBufferedEvents/flushEvents as needed. - */ - -export interface TelemetryEvent { - event: string; - userId?: string; - properties?: Record; - timestamp?: string; -} - -export interface TelemetryBuffer { - trackEvent(event: string, userId?: string, properties?: Record): void; - getBufferedEvents(): TelemetryEvent[]; - flushEvents(): TelemetryEvent[]; -} - -export interface TelemetryBufferOptions { - /** Master switch — when false, trackEvent is a no-op. */ - enabled: boolean; -} - -export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer { - const buffer: TelemetryEvent[] = []; - - return { - trackEvent(event: string, userId?: string, properties?: Record): void { - if (!opts.enabled) return; - buffer.push({ - event, - userId, - properties, - timestamp: new Date().toISOString(), - }); - }, - - getBufferedEvents(): TelemetryEvent[] { - return [...buffer]; - }, - - flushEvents(): TelemetryEvent[] { - const flushed = [...buffer]; - buffer.length = 0; - return flushed; - }, - }; -} diff --git a/vendor/bytelyst/backend-telemetry/tsconfig.json b/vendor/bytelyst/backend-telemetry/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/backend-telemetry/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/billing-client/.gitignore b/vendor/bytelyst/billing-client/.gitignore deleted file mode 100644 index aa1ec1e..0000000 --- a/vendor/bytelyst/billing-client/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.tgz diff --git a/vendor/bytelyst/billing-client/package.json b/vendor/bytelyst/billing-client/package.json deleted file mode 100644 index 7b872b6..0000000 --- a/vendor/bytelyst/billing-client/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@bytelyst/billing-client", - "version": "0.1.0", - "type": "module", - "description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "typescript": "^5.7.3", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/billing-client/src/index.test.ts b/vendor/bytelyst/billing-client/src/index.test.ts deleted file mode 100644 index 8a7c05b..0000000 --- a/vendor/bytelyst/billing-client/src/index.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createBillingClient, BillingApiError } from './index.js'; -import type { BillingClient } from './index.js'; - -function mockFetch(status: number, body: unknown) { - return vi.fn().mockResolvedValue({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - }); -} - -describe('createBillingClient', () => { - let client: BillingClient; - const config = { - baseUrl: 'http://localhost:4003/api', - productId: 'notelett', - getAccessToken: () => 'test-token', - }; - - beforeEach(() => { - client = createBillingClient(config); - }); - - it('listPlans — returns plans array', async () => { - const plans = [{ name: 'free', displayName: 'Free', price: 0 }]; - globalThis.fetch = mockFetch(200, { plans }); - const result = await client.listPlans(); - expect(result).toEqual(plans); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/plans', - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('getPlan — returns single plan', async () => { - const plan = { name: 'pro', displayName: 'Pro', price: 9.99 }; - globalThis.fetch = mockFetch(200, plan); - const result = await client.getPlan('pro'); - expect(result).toEqual(plan); - }); - - it('getSubscription — returns subscription', async () => { - const sub = { id: 'sub_1', plan: 'free', status: 'active' }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.getSubscription(); - expect(result).toEqual(sub); - }); - - it('getSubscription — returns null on 404', async () => { - globalThis.fetch = mockFetch(404, { message: 'Not found' }); - const result = await client.getSubscription(); - expect(result).toBeNull(); - }); - - it('changePlan — sends plan in body', async () => { - const sub = { id: 'sub_1', plan: 'pro', status: 'active' }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.changePlan('pro'); - expect(result.plan).toBe('pro'); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'http://localhost:4003/api/subscriptions/me/change-plan', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ plan: 'pro' }), - }) - ); - }); - - it('cancelSubscription — POST to cancel endpoint', async () => { - const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.cancelSubscription(); - expect(result.cancelAtPeriodEnd).toBe(true); - }); - - it('resumeSubscription — POST to resume endpoint', async () => { - const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false }; - globalThis.fetch = mockFetch(200, sub); - const result = await client.resumeSubscription(); - expect(result.cancelAtPeriodEnd).toBe(false); - }); - - it('listPayments — returns payments array', async () => { - const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }]; - globalThis.fetch = mockFetch(200, { payments }); - const result = await client.listPayments(); - expect(result).toEqual(payments); - }); - - it('getUsage — returns usage summary', async () => { - const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 }; - globalThis.fetch = mockFetch(200, usage); - const result = await client.getUsage(); - expect(result.tokensRemaining).toBe(9500); - }); - - it('sends auth header and product-id header', async () => { - globalThis.fetch = mockFetch(200, { plans: [] }); - await client.listPlans(); - const call = (globalThis.fetch as ReturnType).mock.calls[0]; - const headers = call[1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - expect(headers['x-product-id']).toBe('notelett'); - }); - - it('throws BillingApiError on non-ok response', async () => { - globalThis.fetch = mockFetch(403, { message: 'Forbidden' }); - await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError); - }); - - it('works without access token', async () => { - const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null }); - globalThis.fetch = mockFetch(200, { plans: [] }); - await noAuthClient.listPlans(); - const headers = (globalThis.fetch as ReturnType).mock.calls[0][1].headers; - expect(headers['Authorization']).toBeUndefined(); - }); -}); diff --git a/vendor/bytelyst/billing-client/src/index.ts b/vendor/bytelyst/billing-client/src/index.ts deleted file mode 100644 index 44db5d8..0000000 --- a/vendor/bytelyst/billing-client/src/index.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * @bytelyst/billing-client — Browser/React Native-safe billing client - * - * Wraps platform-service /plans, /subscriptions, /payments, /usage - * endpoints with typed methods. Any ByteLyst product can add billing - * with minimal wiring: - * - * @example - * ```ts - * import { createBillingClient } from '@bytelyst/billing-client'; - * - * const billing = createBillingClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'notelett', - * getAccessToken: () => localStorage.getItem('notelett_access_token'), - * }); - * - * const plans = await billing.listPlans(); - * const sub = await billing.getSubscription(); - * await billing.changePlan('pro'); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export type PlanTier = 'free' | 'pro' | 'enterprise'; -export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing'; -export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded'; - -export interface PlanConfig { - id: string; - productId: string; - name: string; - displayName: string; - price: number; - tokens: number; - words: number; - dictations: number; - features: string[]; - stripePriceId?: string; - active: boolean; - createdAt: string; - updatedAt: string; -} - -export interface Subscription { - id: string; - productId: string; - userId: string; - plan: PlanTier; - status: SubscriptionStatus; - currentPeriodStart: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice: number; - tokensIncluded: number; - tokensUsed: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - createdAt: string; - updatedAt: string; -} - -export interface Payment { - id: string; - productId: string; - userId: string; - amount: number; - currency: string; - status: PaymentStatus; - description: string; - method: string; - invoiceUrl?: string; - createdAt: string; -} - -export interface UsageSummary { - tokensUsed: number; - tokensIncluded: number; - tokensRemaining: number; - percentUsed: number; - currentPeriodStart: string; - currentPeriodEnd: string; -} - -// ── Config ─────────────────────────────────────────────────── - -export interface BillingClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - /** Product identifier. */ - productId: string; - /** Returns current access token, or null if not authenticated. */ - getAccessToken: () => string | null; - /** Request timeout in ms (default: 15000). */ - timeoutMs?: number; -} - -// ── Client Interface ───────────────────────────────────────── - -export interface BillingClient { - /** List available plans for the product. */ - listPlans(): Promise; - /** Get a specific plan by name. */ - getPlan(planName: string): Promise; - /** Get current user's subscription. Returns null if no subscription. */ - getSubscription(): Promise; - /** Change to a different plan (creates or updates subscription). */ - changePlan(plan: PlanTier): Promise; - /** Cancel subscription at period end. */ - cancelSubscription(): Promise; - /** Resume a cancelled subscription (undo cancel-at-period-end). */ - resumeSubscription(): Promise; - /** List payment history. */ - listPayments(): Promise; - /** Get usage summary for current billing period. */ - getUsage(): Promise; -} - -// ── Errors ─────────────────────────────────────────────────── - -export class BillingApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `Billing API error ${status}`); - this.name = 'BillingApiError'; - } -} - -// ── Factory ────────────────────────────────────────────────── - -export function createBillingClient(config: BillingClientConfig): BillingClient { - const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config; - - async function request(method: string, path: string, body?: unknown): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - }; - - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(`${baseUrl}${path}`, { - method, - headers, - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (res.status === 204) return undefined as T; - if (res.status === 404) return null as T; - - const json = await res.json().catch(() => ({})); - - if (!res.ok) { - throw new BillingApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - return { - async listPlans() { - const result = await request<{ plans: PlanConfig[] }>('GET', '/plans'); - return result.plans; - }, - - async getPlan(planName: string) { - return request('GET', `/plans/${encodeURIComponent(planName)}`); - }, - - async getSubscription() { - // Platform-service expects userId in path; the userId comes from the JWT. - // We use a convenience endpoint that reads userId from the token. - return request('GET', '/subscriptions/me'); - }, - - async changePlan(plan: PlanTier) { - return request('POST', '/subscriptions/me/change-plan', { plan }); - }, - - async cancelSubscription() { - return request('POST', '/subscriptions/me/cancel'); - }, - - async resumeSubscription() { - return request('POST', '/subscriptions/me/resume'); - }, - - async listPayments() { - const result = await request<{ payments: Payment[] }>('GET', '/payments/me'); - return result.payments; - }, - - async getUsage() { - return request('GET', '/subscriptions/me/usage'); - }, - }; -} diff --git a/vendor/bytelyst/billing-client/tsconfig.json b/vendor/bytelyst/billing-client/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/billing-client/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/blob-client/package.json b/vendor/bytelyst/blob-client/package.json deleted file mode 100644 index 82f8bcd..0000000 --- a/vendor/bytelyst/blob-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/blob-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe blob storage client — SAS URL upload/download via platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/blob-client/src/index.test.ts b/vendor/bytelyst/blob-client/src/index.test.ts deleted file mode 100644 index 8ddad34..0000000 --- a/vendor/bytelyst/blob-client/src/index.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createBlobClient, BlobApiError, BlobUploadError } from './index.js'; - -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -function jsonResponse(data: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), - headers: new Headers(), - } as unknown as Response; -} - -function blobClient(overrides?: Partial[0]>) { - return createBlobClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', - ...overrides, - }); -} - -beforeEach(() => { - mockFetch.mockReset(); -}); - -describe('createBlobClient', () => { - describe('getSasUrl', () => { - it('requests a SAS URL from platform-service', async () => { - const sasResponse = { - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'r', - expiresInMinutes: 60, - expiresAt: '2026-03-02T00:00:00.000Z', - }; - mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse)); - - const client = blobClient(); - const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg'); - - expect(result).toEqual(sasResponse); - expect(mockFetch).toHaveBeenCalledOnce(); - - const [url, init] = mockFetch.mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/blob/sas'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body)).toEqual({ - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'r', - expiresInMinutes: 60, - }); - }); - - it('sends auth and product headers', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); - - const client = blobClient(); - await client.getSasUrl('attachments', 'blob.jpg'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBe('Bearer test-token'); - expect(headers['x-product-id']).toBe('testapp'); - expect(headers['x-request-id']).toBeDefined(); - }); - - it('omits auth header when no token', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' })); - - const client = blobClient({ getAccessToken: () => null }); - await client.getSasUrl('attachments', 'blob.jpg'); - - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers['Authorization']).toBeUndefined(); - }); - - it('throws BlobApiError on non-ok response', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401)); - - const client = blobClient(); - await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError); - }); - }); - - describe('upload', () => { - it('requests SAS then uploads directly to Azure', async () => { - const sasResponse = { - sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc', - container: 'attachments', - blobName: 'testapp/user1/photo.jpg', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }; - mockFetch - .mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request - .mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload - - const client = blobClient(); - const result = await client.upload('attachments', 'file-data', { - contentType: 'image/jpeg', - blobName: 'testapp/user1/photo.jpg', - }); - - expect(result.container).toBe('attachments'); - expect(result.blobName).toBe('testapp/user1/photo.jpg'); - expect(result.sasUrl).toBe( - 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg' - ); - - // Verify Azure upload call - const [uploadUrl, uploadInit] = mockFetch.mock.calls[1]; - expect(uploadUrl).toBe(sasResponse.sasUrl); - expect(uploadInit.method).toBe('PUT'); - expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob'); - expect(uploadInit.headers['Content-Type']).toBe('image/jpeg'); - expect(uploadInit.body).toBe('file-data'); - }); - - it('auto-generates blobName when not provided', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x', - container: 'attachments', - blobName: 'testapp/123-abc', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: true, status: 201 } as Response); - - const client = blobClient(); - const result = await client.upload('attachments', 'data', { - contentType: 'text/plain', - }); - - // Verify the SAS request included a generated blobName starting with productId - const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sasBody.blobName).toMatch(/^testapp\//); - expect(result.container).toBe('attachments'); - }); - - it('throws BlobUploadError when Azure returns non-ok', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc', - container: 'attachments', - blobName: 'blob', - permissions: 'w', - expiresInMinutes: 30, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: false, status: 403 } as Response); - - const client = blobClient(); - await expect( - client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' }) - ).rejects.toThrow(BlobUploadError); - }); - }); - - describe('download', () => { - it('requests read SAS then fetches the blob', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', - container: 'attachments', - blobName: 'blob', - permissions: 'r', - expiresInMinutes: 15, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ - ok: true, - status: 200, - blob: () => Promise.resolve(new Blob()), - } as unknown as Response); - - const client = blobClient(); - const res = await client.download('attachments', 'blob'); - - expect(res.ok).toBe(true); - expect(mockFetch).toHaveBeenCalledTimes(2); - - // Second call should be to the SAS URL - expect(mockFetch.mock.calls[1][0]).toBe( - 'https://storage.blob.core.windows.net/attachments/blob?sig=read' - ); - }); - - it('throws BlobApiError on download failure', async () => { - mockFetch - .mockResolvedValueOnce( - jsonResponse({ - sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read', - container: 'attachments', - blobName: 'blob', - permissions: 'r', - expiresInMinutes: 15, - expiresAt: '2026-03-02T00:00:00.000Z', - }) - ) - .mockResolvedValueOnce({ ok: false, status: 404 } as Response); - - const client = blobClient(); - await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError); - }); - }); - - describe('list', () => { - it('lists blobs with prefix and limit', async () => { - const listResponse = { - blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }], - count: 1, - container: 'attachments', - prefix: 'testapp/user1/', - }; - mockFetch.mockResolvedValueOnce(jsonResponse(listResponse)); - - const client = blobClient(); - const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 }); - - expect(result).toEqual(listResponse); - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('container=attachments'); - expect(url).toContain('prefix=testapp%2Fuser1%2F'); - expect(url).toContain('limit=10'); - }); - - it('works without options', async () => { - mockFetch.mockResolvedValueOnce( - jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null }) - ); - - const client = blobClient(); - await client.list('audio'); - - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('container=audio'); - expect(url).not.toContain('prefix='); - expect(url).not.toContain('limit='); - }); - }); - - describe('info', () => { - it('fetches blob metadata', async () => { - const infoResponse = { - name: 'testapp/user1/photo.jpg', - container: 'attachments', - contentType: 'image/jpeg', - size: 2048, - lastModified: '2026-03-01T00:00:00.000Z', - url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg', - metadata: {}, - }; - mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse)); - - const client = blobClient(); - const result = await client.info('attachments', 'testapp/user1/photo.jpg'); - - expect(result).toEqual(infoResponse); - const url = mockFetch.mock.calls[0][0] as string; - expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg'); - }); - }); -}); diff --git a/vendor/bytelyst/blob-client/src/index.ts b/vendor/bytelyst/blob-client/src/index.ts deleted file mode 100644 index f0bf9a9..0000000 --- a/vendor/bytelyst/blob-client/src/index.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Browser/React Native-safe blob storage client. - * - * Wraps the platform-service blob endpoints to provide: - * - SAS URL generation for direct upload/download - * - Direct blob upload via SAS URL (PUT with raw body) - * - Direct blob download via SAS URL - * - Blob listing and metadata - * - * Requires a fetch-compatible environment (browser, React Native, Node 18+). - * - * @example - * ```ts - * import { createBlobClient } from '@bytelyst/blob-client'; - * - * const blob = createBlobClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * getAccessToken: () => authClient.getAccessToken(), - * }); - * - * // Upload a file - * const { url } = await blob.upload('attachments', file, { - * contentType: 'image/jpeg', - * blobName: 'nomgap/user123/photos/meal.jpg', - * }); - * - * // Download a file - * const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg'); - * - * // List blobs - * const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface BlobClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Function that returns the current access token, or null. */ - getAccessToken: () => string | null; - - /** Request timeout in milliseconds for API calls. Default: 15000. */ - timeoutMs?: number; - - /** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */ - uploadTimeoutMs?: number; -} - -export interface SasUrlResponse { - sasUrl: string; - container: string; - blobName: string; - permissions: string; - expiresInMinutes: number; - expiresAt: string; -} - -export interface BlobInfo { - name: string; - container: string; - contentType?: string; - size: number; - lastModified?: string; - url: string; - metadata: Record; -} - -export interface ListBlobsResponse { - blobs: BlobInfo[]; - count: number; - container: string; - prefix: string | null; -} - -export interface UploadOptions { - /** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */ - contentType: string; - - /** Full blob path. If omitted, auto-generated as `//-`. */ - blobName?: string; - - /** SAS token expiry in minutes. Default: 30. */ - expiresInMinutes?: number; -} - -export interface UploadResult { - sasUrl: string; - container: string; - blobName: string; -} - -export interface BlobClient { - /** Get a SAS URL for direct upload or download. */ - getSasUrl( - container: string, - blobName: string, - permissions?: 'r' | 'w' | 'rw' | 'rwc', - expiresInMinutes?: number - ): Promise; - - /** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */ - upload( - container: string, - data: Blob | ArrayBuffer | Uint8Array | string, - options: UploadOptions - ): Promise; - - /** Download a blob via SAS URL. Returns the Response for streaming. */ - download(container: string, blobName: string): Promise; - - /** List blobs in a container. */ - list( - container: string, - options?: { prefix?: string; limit?: number } - ): Promise; - - /** Get blob metadata/info. */ - info(container: string, blobName: string): Promise; -} - -// ── Errors ─────────────────────────────────────────────────── - -export class BlobApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `Blob API error ${status}`); - this.name = 'BlobApiError'; - } -} - -export class BlobUploadError extends Error { - constructor( - public readonly status: number, - message?: string - ) { - super(message ?? `Blob upload failed with status ${status}`); - this.name = 'BlobUploadError'; - } -} - -// ── UUID helper ────────────────────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────── - -export function createBlobClient(config: BlobClientConfig): BlobClient { - const { - baseUrl, - productId, - getAccessToken, - timeoutMs = 15_000, - uploadTimeoutMs = 120_000, - } = config; - - function authHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }; - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - return headers; - } - - async function apiRequest(method: string, path: string, body?: unknown): Promise { - const url = `${baseUrl}${path}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(url, { - method, - headers: authHeaders(), - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - const json = await res.json().catch(() => ({})); - - if (!res.ok) { - throw new BlobApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - async function getSasUrl( - container: string, - blobName: string, - permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r', - expiresInMinutes = 60 - ): Promise { - return apiRequest('POST', '/blob/sas', { - container, - blobName, - permissions, - expiresInMinutes, - }); - } - - async function upload( - container: string, - data: Blob | ArrayBuffer | Uint8Array | string, - options: UploadOptions - ): Promise { - const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`; - - const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), uploadTimeoutMs); - - try { - const res = await globalThis.fetch(sas.sasUrl, { - method: 'PUT', - headers: { - 'x-ms-blob-type': 'BlockBlob', - 'Content-Type': options.contentType, - }, - body: data as BodyInit, - signal: controller.signal, - }); - - if (!res.ok) { - throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`); - } - - return { sasUrl: sas.sasUrl.split('?')[0], container, blobName }; - } finally { - clearTimeout(timer); - } - } - - async function download(container: string, blobName: string): Promise { - const sas = await getSasUrl(container, blobName, 'r', 15); - - const res = await globalThis.fetch(sas.sasUrl); - if (!res.ok) { - throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`); - } - return res; - } - - async function list( - container: string, - options?: { prefix?: string; limit?: number } - ): Promise { - const params = new URLSearchParams({ container }); - if (options?.prefix) params.set('prefix', options.prefix); - if (options?.limit) params.set('limit', String(options.limit)); - - return apiRequest('GET', `/blob/list?${params.toString()}`); - } - - async function info(container: string, blobName: string): Promise { - return apiRequest( - 'GET', - `/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}` - ); - } - - return { getSasUrl, upload, download, list, info }; -} diff --git a/vendor/bytelyst/blob-client/tsconfig.json b/vendor/bytelyst/blob-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/blob-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/blob/package.json b/vendor/bytelyst/blob/package.json deleted file mode 100644 index 8df2583..0000000 --- a/vendor/bytelyst/blob/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/blob", - "version": "0.2.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "pretest": "pnpm --dir ../.. --filter @bytelyst/storage build", - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/storage": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/blob/src/__tests__/blob.test.ts b/vendor/bytelyst/blob/src/__tests__/blob.test.ts deleted file mode 100644 index c735c96..0000000 --- a/vendor/bytelyst/blob/src/__tests__/blob.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { setStorage, MemoryStorageProvider } from '@bytelyst/storage'; - -import { - _resetBlobClient, - generateSasUrl, - getBucket, - getStorageProvider, - isBlobStorageConfigured, - BLOB_CONTAINERS, -} from '../index.js'; - -describe('blob', () => { - beforeEach(() => { - _resetBlobClient(); - delete process.env.AZURE_BLOB_CONNECTION_STRING; - delete process.env.AZURE_BLOB_ACCOUNT_NAME; - delete process.env.AZURE_BLOB_ACCOUNT_KEY; - delete process.env.STORAGE_PROVIDER; - }); - - afterEach(() => { - _resetBlobClient(); - delete process.env.AZURE_BLOB_CONNECTION_STRING; - delete process.env.AZURE_BLOB_ACCOUNT_NAME; - delete process.env.AZURE_BLOB_ACCOUNT_KEY; - delete process.env.STORAGE_PROVIDER; - }); - - describe('isBlobStorageConfigured', () => { - it('is false when unset', () => { - expect(isBlobStorageConfigured()).toBe(false); - }); - - it('is true when connection string is set', () => { - process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;'; - expect(isBlobStorageConfigured()).toBe(true); - }); - - it('is true when account name + key are set', () => { - process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc'; - process.env.AZURE_BLOB_ACCOUNT_KEY = 'key=='; - expect(isBlobStorageConfigured()).toBe(true); - }); - - it('is true when provider is memory', () => { - process.env.STORAGE_PROVIDER = 'memory'; - expect(isBlobStorageConfigured()).toBe(true); - }); - }); - - describe('with memory provider', () => { - let memoryProvider: MemoryStorageProvider; - - beforeEach(() => { - memoryProvider = new MemoryStorageProvider(); - setStorage(memoryProvider); - }); - - it('getStorageProvider returns the provider', async () => { - const provider = await getStorageProvider(); - expect(provider).toBe(memoryProvider); - }); - - it('getBucket returns a bucket by name', async () => { - const bucket = await getBucket('audio'); - expect(bucket).toBeDefined(); - // Upload and download to verify it works - await bucket.upload('test.wav', Buffer.from('hello')); - const data = await bucket.download('test.wav'); - expect(data.toString()).toBe('hello'); - }); - - it('generateSasUrl returns a signed URL', async () => { - const url = await generateSasUrl('audio', 'path/file.wav', 'r', 10); - expect(url).toContain('audio'); - expect(url).toContain('path/file.wav'); - expect(url).toContain('signed=true'); - }); - - it('generateSasUrl defaults to read permissions', async () => { - const url = await generateSasUrl('audio', 'file.wav'); - expect(url).toContain('signed=true'); - }); - - it('_resetBlobClient resets the storage singleton', async () => { - const p1 = await getStorageProvider(); - _resetBlobClient(); - // After reset, inject a new provider - const newProvider = new MemoryStorageProvider(); - setStorage(newProvider); - const p2 = await getStorageProvider(); - expect(p1).not.toBe(p2); - }); - }); - - describe('BLOB_CONTAINERS', () => { - it('has expected container names', () => { - expect(BLOB_CONTAINERS.audio).toBe('audio'); - expect(BLOB_CONTAINERS.transcripts).toBe('transcripts'); - expect(BLOB_CONTAINERS.attachments).toBe('attachments'); - expect(BLOB_CONTAINERS.avatars).toBe('avatars'); - expect(BLOB_CONTAINERS.releases).toBe('releases'); - expect(BLOB_CONTAINERS.backups).toBe('backups'); - expect(BLOB_CONTAINERS.feedbackScreenshots).toBe('feedback-screenshots'); - }); - }); -}); diff --git a/vendor/bytelyst/blob/src/blob.ts b/vendor/bytelyst/blob/src/blob.ts deleted file mode 100644 index 71e0d82..0000000 --- a/vendor/bytelyst/blob/src/blob.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Shared Blob Storage utilities. - * - * Delegates to @bytelyst/storage for provider-agnostic blob operations. - * Keeps the same exported API surface for backward compatibility. - * - * Expected env vars: - * STORAGE_PROVIDER — 'azure' (default) | 'memory' - * AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure) - * — OR — - * AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY - */ - -import { - getStorage, - _resetStorage, - type StorageProvider, - type StorageBucket, -} from '@bytelyst/storage'; - -/** - * Known blob containers and their purposes. - * - * Note: This is a convenience list (not enforced). Products can add their own - * containers as needed. - */ -export const BLOB_CONTAINERS = { - audio: 'audio', // Dictation audio recordings - transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT) - attachments: 'attachments', // Tracker item attachments (screenshots, docs) - avatars: 'avatars', // User profile images - releases: 'releases', // Desktop app update binaries - backups: 'backups', // Cosmos DB JSON backups - feedbackScreenshots: 'feedback-screenshots', // User feedback screenshot attachments -} as const; - -export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS]; - -/** - * Get the storage provider singleton. - */ -export async function getStorageProvider(): Promise { - return getStorage(); -} - -/** - * Get a bucket (container) by name. - */ -export async function getBucket(containerName: string): Promise { - const storage = await getStorage(); - return storage.getBucket(containerName); -} - -/** - * Generate a signed URL for direct browser upload (or download). - * - * @param containerName - Target container - * @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav") - * @param permissions - SAS permissions (default: read) - * @param expiresInMinutes - Token lifetime (default: 60) - * @returns Full signed URL for the blob - */ -export async function generateSasUrl( - containerName: string, - blobName: string, - permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r', - expiresInMinutes = 60 -): Promise { - const bucket = await getBucket(containerName); - const perm = permissions.includes('w') ? ('write' as const) : ('read' as const); - return bucket.getSignedUrl(blobName, { - permissions: perm, - expiresIn: expiresInMinutes * 60, - }); -} - -/** - * Check if blob storage is configured. - */ -export function isBlobStorageConfigured(): boolean { - const provider = process.env.STORAGE_PROVIDER || 'azure'; - if (provider === 'memory') return true; - return !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) - ); -} - -/** - * Test helper: reset module singletons/caches. - */ -export function _resetBlobClient(): void { - _resetStorage(); -} diff --git a/vendor/bytelyst/blob/src/index.ts b/vendor/bytelyst/blob/src/index.ts deleted file mode 100644 index 10b6d7c..0000000 --- a/vendor/bytelyst/blob/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './blob.js'; diff --git a/vendor/bytelyst/blob/tsconfig.json b/vendor/bytelyst/blob/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/blob/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/broadcast-client/README.md b/vendor/bytelyst/broadcast-client/README.md deleted file mode 100644 index 3227c51..0000000 --- a/vendor/bytelyst/broadcast-client/README.md +++ /dev/null @@ -1,227 +0,0 @@ -# @bytelyst/broadcast-client - -TypeScript client for the ByteLyst Broadcast & Messaging platform. Provides in-app message polling, read receipts, and push notification token management. - -## Installation - -```bash -npm install @bytelyst/broadcast-client -# or -pnpm add @bytelyst/broadcast-client -``` - -## Quick Start - -```typescript -import { createBroadcastClient } from '@bytelyst/broadcast-client'; - -const client = createBroadcastClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: async () => { - // Return your JWT token - return localStorage.getItem('token'); - } -}); - -// Start polling for messages (every 60 seconds) -client.startPolling(60000, (messages) => { - console.log('New messages:', messages); -}); -``` - -## API Reference - -### `createBroadcastClient(config)` - -Creates a new broadcast client instance. - -**Config:** -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `baseURL` | string | Yes | API base URL | -| `productId` | string | Yes | Product identifier | -| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | - -### Methods - -#### `getMessages()` -Fetch active in-app messages for the current user. - -```typescript -const { data, error } = await client.getMessages(); -// Returns: { messages: InAppMessage[] } -``` - -#### `markRead(messageId: string)` -Mark a message as read. - -```typescript -await client.markRead('msg_123'); -``` - -#### `markDismissed(messageId: string)` -Dismiss a message. - -```typescript -await client.markDismissed('msg_123'); -``` - -#### `trackClick(messageId: string)` -Track when user clicks/taps a message CTA. - -```typescript -await client.trackClick('msg_123'); -``` - -#### `startPolling(intervalMs: number, callback: (messages) => void)` -Start polling for new messages. - -```typescript -client.startPolling(60000, (messages) => { - // Called every 60 seconds with current messages -}); -``` - -#### `stopPolling()` -Stop message polling. - -```typescript -client.stopPolling(); -``` - -#### `registerDeviceToken(token: string, platform: 'ios' | 'android' | 'web')` -Register push notification device token. - -```typescript -await client.registerDeviceToken('fcm_token_xyz', 'android'); -``` - -#### `unregisterDeviceToken(token: string)` -Unregister device token (e.g., on logout). - -```typescript -await client.unregisterDeviceToken('fcm_token_xyz'); -``` - -## React Integration - -### Hook Usage - -```typescript -import { useBroadcastClient } from './hooks/useBroadcastClient'; - -function App() { - const { messages, unreadCount, markRead, markDismissed } = useBroadcastClient({ - pollingInterval: 60000 - }); - - return ( -
- {messages.map(msg => ( - markDismissed(msg.id)} - onClick={() => markRead(msg.id)} - /> - ))} -
- ); -} -``` - -### Provider Pattern - -```typescript -// BroadcastProvider.tsx -import { createContext, useContext, useEffect, useState } from 'react'; -import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client'; - -const BroadcastContext = createContext(null); - -export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) { - const [client] = useState(() => createBroadcastClient(config)); - const [messages, setMessages] = useState([]); - - useEffect(() => { - client.startPolling(60000, setMessages); - return () => client.stopPolling(); - }, [client]); - - const value = { - messages, - unreadCount: messages.filter(m => m.status === 'unread').length, - markRead: client.markRead.bind(client), - markDismissed: client.markDismissed.bind(client), - trackClick: client.trackClick.bind(client), - }; - - return ( - - {children} - - ); -} - -export const useBroadcast = () => { - const ctx = useContext(BroadcastContext); - if (!ctx) throw new Error('useBroadcast must be used within BroadcastProvider'); - return ctx; -}; -``` - -## Types - -```typescript -interface InAppMessage { - id: string; - broadcastId: string; - title: string; - body?: string; - style: 'banner' | 'modal' | 'fullscreen' | 'toast'; - priority: 'low' | 'normal' | 'high' | 'urgent'; - ctaText?: string; - ctaUrl?: string; - imageUrl?: string; - deepLink?: { - screen: string; - params: Record; - }; - status: 'unread' | 'read' | 'dismissed'; - createdAt: string; -} - -interface BroadcastConfig { - baseURL: string; - productId: string; - getAuthToken: () => Promise; -} -``` - -## Error Handling - -All methods return a result tuple `[data, error]`: - -```typescript -const [data, error] = await client.getMessages(); - -if (error) { - console.error('Failed to fetch messages:', error.message); - return; -} - -// Use data.messages -``` - -## Browser Support - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/broadcast-client/package.json b/vendor/bytelyst/broadcast-client/package.json deleted file mode 100644 index 202a9f8..0000000 --- a/vendor/bytelyst/broadcast-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/broadcast-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe broadcast messaging client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/broadcast-client/src/deep-link.ts b/vendor/bytelyst/broadcast-client/src/deep-link.ts deleted file mode 100644 index ee4ecc8..0000000 --- a/vendor/bytelyst/broadcast-client/src/deep-link.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Deep Link Router — TypeScript - * Handles routing from push notification deep links to app screens - */ - -export interface DeepLinkRoute { - screen: string; - params?: Record; -} - -export type DeepLinkHandler = (route: DeepLinkRoute) => void; - -/** - * Deep Link Router class - */ -export class DeepLinkRouter { - private handlers = new Map(); - private fallbackHandler?: DeepLinkHandler; - - /** - * Register a handler for a specific screen - */ - register(screen: string, handler: DeepLinkHandler): void { - this.handlers.set(screen, handler); - } - - /** - * Set a fallback handler for unregistered screens - */ - setFallback(handler: DeepLinkHandler): void { - this.fallbackHandler = handler; - } - - /** - * Parse a deep link URL and extract route - */ - parseDeepLink(url: string): DeepLinkRoute | null { - try { - const urlObj = new URL(url); - - // Handle app-specific URLs: myapp://screen/params - if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { - const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter( - Boolean - ); - const screen = routeParts[0] || 'home'; - const params: Record = {}; - - // Parse query params - urlObj.searchParams.forEach((value, key) => { - params[key] = value; - }); - - return { screen, params }; - } - - // Handle web URLs with deep link params - const deepLinkParam = urlObj.searchParams.get('dl'); - if (deepLinkParam) { - return this.parseDeepLink(deepLinkParam); - } - - // Handle path-based routing: /screen/params - const pathParts = urlObj.pathname.split('/').filter(Boolean); - if (pathParts.length > 0) { - const screen = pathParts[0]; - const params: Record = {}; - - urlObj.searchParams.forEach((value, key) => { - params[key] = value; - }); - - return { screen, params }; - } - - return null; - } catch { - return null; - } - } - - /** - * Handle a deep link route - */ - handle(route: DeepLinkRoute): boolean { - const handler = this.handlers.get(route.screen); - - if (handler) { - handler(route); - return true; - } - - if (this.fallbackHandler) { - this.fallbackHandler(route); - return true; - } - - // eslint-disable-next-line no-console -- Deep-link consumers need an opt-in diagnostic when a route is dropped. - console.warn(`[DeepLink] No handler for screen: ${route.screen}`); - return false; - } - - /** - * Process a deep link URL end-to-end - */ - process(url: string): boolean { - const route = this.parseDeepLink(url); - if (!route) { - // eslint-disable-next-line no-console -- Parse failures are intentionally surfaced to host apps during integration. - console.warn(`[DeepLink] Failed to parse: ${url}`); - return false; - } - return this.handle(route); - } -} - -/** - * Create a broadcast deep link URL - */ -export function createBroadcastDeepLink( - baseUrl: string, - screen: string, - params?: Record, - broadcastId?: string -): string { - const url = new URL(baseUrl); - url.pathname = `/${screen}`; - - if (params) { - Object.entries(params).forEach(([key, value]) => { - url.searchParams.set(key, value); - }); - } - - if (broadcastId) { - url.searchParams.set('broadcastId', broadcastId); - } - - return url.toString(); -} - -/** - * Common deep link screens for broadcast/survey flows - */ -export const DeepLinkScreens = { - // Broadcasts - BROADCAST_DETAIL: 'broadcast', - ANNOUNCEMENTS: 'announcements', - - // Surveys - SURVEY: 'survey', - SURVEY_LIST: 'surveys', - - // Product-specific (examples) - SETTINGS: 'settings', - PROFILE: 'profile', - UPGRADE: 'upgrade', - SUPPORT: 'support', - - // Fallback - HOME: 'home', -} as const; - -// Singleton instance for app-wide use -export const deepLinkRouter = new DeepLinkRouter(); diff --git a/vendor/bytelyst/broadcast-client/src/index.test.ts b/vendor/bytelyst/broadcast-client/src/index.test.ts deleted file mode 100644 index f4464dd..0000000 --- a/vendor/bytelyst/broadcast-client/src/index.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - DeepLinkRouter, - DeepLinkScreens, - createBroadcastClient, - createBroadcastDeepLink, - createUseBroadcast, -} from './index.js'; - -const fetchMock = vi.fn(); - -function jsonResponse(body: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), - }; -} - -describe('createBroadcastClient', () => { - beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal('fetch', fetchMock); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('lists messages with default segment and auth headers', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const result = await client.listMessages(); - - expect(result).toEqual({ messages: [{ id: 'm1' }] }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/broadcasts', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer token-123', - 'x-product-id': 'testapp', - 'x-platform': 'web', - 'x-app-version': '1.2.3', - 'x-os-version': 'macOS 15', - 'x-user-segments': 'free', - }), - }) - ); - }); - - it('supports async auth token resolution and optional headers', async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: async () => 'async-token', - platform: 'ios', - appVersion: '2.0.0', - osVersion: '18.0', - countryCode: 'US', - regionCode: 'CA', - userSegments: ['pro', 'beta'], - }); - - await client.listMessages(); - - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/broadcasts', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer async-token', - 'x-country-code': 'US', - 'x-region-code': 'CA', - 'x-user-segments': 'pro,beta', - }), - }) - ); - }); - - it('throws a descriptive error when the API fails', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve('boom'), - }); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom'); - }); - - it('polls messages and returns a cleanup function', async () => { - vi.useFakeTimers(); - fetchMock.mockResolvedValue(jsonResponse({ messages: [] })); - - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const stopPolling = client.pollMessages(1000); - await vi.advanceTimersByTimeAsync(3000); - - expect(fetchMock).toHaveBeenCalledTimes(3); - - stopPolling(); - await vi.advanceTimersByTimeAsync(2000); - - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it('returns the same client from createUseBroadcast', () => { - const client = createBroadcastClient({ - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAuthToken: () => 'token-123', - platform: 'web', - appVersion: '1.2.3', - osVersion: 'macOS 15', - }); - - const useBroadcast = createUseBroadcast(client); - - expect(useBroadcast()).toEqual({ client }); - }); -}); - -describe('DeepLinkRouter', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('parses custom app deep links using the host as the screen', () => { - const router = new DeepLinkRouter(); - - expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({ - screen: 'broadcast', - params: { - broadcastId: 'b1', - variant: 'test', - }, - }); - }); - - it('parses nested deep links from web URLs', () => { - const router = new DeepLinkRouter(); - - expect( - router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1') - ).toEqual({ - screen: 'survey', - params: { id: 's1' }, - }); - }); - - it('dispatches to registered handlers and falls back when needed', () => { - const router = new DeepLinkRouter(); - const primaryHandler = vi.fn(); - const fallbackHandler = vi.fn(); - - router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler); - router.setFallback(fallbackHandler); - - expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe( - true - ); - expect(primaryHandler).toHaveBeenCalledWith({ - screen: DeepLinkScreens.BROADCAST_DETAIL, - params: { id: 'b1' }, - }); - - expect(router.handle({ screen: 'unknown' })).toBe(true); - expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' }); - }); - - it('returns false and warns when processing an invalid URL without a fallback', () => { - const router = new DeepLinkRouter(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - expect(router.process('not-a-url')).toBe(false); - expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url'); - }); -}); - -describe('createBroadcastDeepLink', () => { - it('builds deep links with params and broadcastId', () => { - expect( - createBroadcastDeepLink( - 'https://app.bytelyst.dev', - DeepLinkScreens.ANNOUNCEMENTS, - { tab: 'latest' }, - 'b42' - ) - ).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42'); - }); -}); diff --git a/vendor/bytelyst/broadcast-client/src/index.ts b/vendor/bytelyst/broadcast-client/src/index.ts deleted file mode 100644 index 2824e2b..0000000 --- a/vendor/bytelyst/broadcast-client/src/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Broadcast Client — Browser/React Native-safe broadcast messaging client - * @module @bytelyst/broadcast-client - */ - -// ============================================================================= -// Types -// ============================================================================= - -export interface Broadcast { - id: string; - productId: string; - title: string; - body: string; - bodyMarkdown?: string; - ctaText?: string; - ctaUrl?: string; - imageUrl?: string; - channels: ('push' | 'in_app' | 'email')[]; - status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused'; - scheduledAt?: string; - sentAt?: string; - variant?: 'control' | 'treatment'; - experimentId?: string; - parentBroadcastId?: string; - metrics: BroadcastMetrics; - createdAt: string; - updatedAt: string; - createdBy: string; -} - -export interface BroadcastMetrics { - targetedCount: number; - sentCount: number; - deliveredCount: number; - openedCount: number; - clickedCount: number; - dismissedCount: number; - convertedCount: number; -} - -export interface InAppMessage { - id: string; - userId: string; - productId: string; - broadcastId: string; - title: string; - body: string; - bodyMarkdown?: string; - ctaText?: string; - ctaUrl?: string; - priority: 'low' | 'normal' | 'high' | 'urgent'; - style: 'banner' | 'modal' | 'toast' | 'fullscreen'; - dismissible: boolean; - expiresAt?: string; - status: 'unread' | 'read' | 'dismissed'; - createdAt: string; - updatedAt: string; -} - -export interface BroadcastClientConfig { - /** Platform service base URL */ - baseUrl: string; - /** Product ID */ - productId: string; - /** Auth token provider (async or sync) */ - getAuthToken: (() => string) | (() => Promise); - /** Platform identifier */ - platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; - /** App version */ - appVersion: string; - /** OS version */ - osVersion: string; - /** Optional country code */ - countryCode?: string; - /** Optional region code */ - regionCode?: string; - /** User segments (default: ['free']) */ - userSegments?: string[]; -} - -// ============================================================================= -// Client Factory -// ============================================================================= - -export interface BroadcastClient { - /** List active in-app messages for current user */ - listMessages(): Promise<{ messages: InAppMessage[] }>; - /** Mark message as read */ - markRead(messageId: string): Promise; - /** Mark message as dismissed */ - markDismissed(messageId: string): Promise; - /** Track CTA click */ - trackClick(messageId: string): Promise<{ redirectUrl?: string }>; - /** Poll for new messages (use with setInterval) */ - pollMessages(intervalMs?: number): () => void; -} - -export function createBroadcastClient(config: BroadcastClientConfig): BroadcastClient { - const headers = async () => ({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, - 'x-product-id': config.productId, - 'x-platform': config.platform, - 'x-app-version': config.appVersion, - 'x-os-version': config.osVersion, - ...(config.countryCode && { 'x-country-code': config.countryCode }), - ...(config.regionCode && { 'x-region-code': config.regionCode }), - 'x-user-segments': (config.userSegments ?? ['free']).join(','), - }); - - const request = async (path: string, options?: RequestInit): Promise => { - const res = await fetch(`${config.baseUrl}${path}`, { - ...options, - headers: { - ...(await headers()), - ...(options?.headers || {}), - }, - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`Broadcast API error: ${res.status} ${err}`); - } - return res.json() as Promise; - }; - - let pollInterval: ReturnType | null = null; - - return { - async listMessages() { - return request<{ messages: InAppMessage[] }>('/broadcasts'); - }, - - async markRead(messageId: string) { - await request(`/broadcasts/${messageId}/read`, { method: 'POST' }); - }, - - async markDismissed(messageId: string) { - await request(`/broadcasts/${messageId}/dismiss`, { method: 'POST' }); - }, - - async trackClick(messageId: string) { - return request<{ redirectUrl?: string }>(`/broadcasts/${messageId}/click`, { - method: 'POST', - }); - }, - - pollMessages(intervalMs = 60000) { - if (pollInterval) clearInterval(pollInterval); - pollInterval = setInterval(() => { - this.listMessages().catch(() => {}); - }, intervalMs); - return () => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }; - }, - }; -} - -// ============================================================================= -// React Hook (optional) -// ============================================================================= - -export function createUseBroadcast(client: BroadcastClient) { - return function useBroadcast() { - return { client }; - }; -} - -// ============================================================================= -// Deep Link Router -// ============================================================================= - -export { - DeepLinkRouter, - deepLinkRouter, - DeepLinkScreens, - createBroadcastDeepLink, - type DeepLinkRoute, - type DeepLinkHandler, -} from './deep-link.js'; - diff --git a/vendor/bytelyst/broadcast-client/tsconfig.json b/vendor/bytelyst/broadcast-client/tsconfig.json deleted file mode 100644 index 3686f56..0000000 --- a/vendor/bytelyst/broadcast-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/celebrations/package.json b/vendor/bytelyst/celebrations/package.json deleted file mode 100644 index 9a150c5..0000000 --- a/vendor/bytelyst/celebrations/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/celebrations", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/celebrations/src/index.ts b/vendor/bytelyst/celebrations/src/index.ts deleted file mode 100644 index 0170132..0000000 --- a/vendor/bytelyst/celebrations/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface Celebration { - emoji: string; - title: string; -} - -const DEFAULT_CELEBRATION: Celebration = { - emoji: '👏', - title: 'Great Job!', -}; - -const BY_TYPE: Record = { - session_completed: { emoji: '🎉', title: 'Fast Complete!' }, - task_completed: { emoji: '✅', title: 'Well Done!' }, - streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' }, - achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' }, - level_up: { emoji: '⬆️', title: 'Level Up!' }, -}; - -export function createCelebrationEngine() { - return { - getCelebration(type: string): Celebration { - return BY_TYPE[type] ?? DEFAULT_CELEBRATION; - }, - }; -} diff --git a/vendor/bytelyst/celebrations/tsconfig.json b/vendor/bytelyst/celebrations/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/celebrations/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/client-encrypt/package.json b/vendor/bytelyst/client-encrypt/package.json deleted file mode 100644 index a1d2937..0000000 --- a/vendor/bytelyst/client-encrypt/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/client-encrypt", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts deleted file mode 100644 index 0c7d044..0000000 --- a/vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - encryptField, - decryptField, - generateKey, - keyFromHex, - keyToHex, - deriveKey, -} from './aes-gcm.js'; -import { isEncryptedField } from './guards.js'; -import { toHex, fromHex } from './hex.js'; - -describe('encryptField / decryptField', () => { - it('roundtrip', async () => { - const key = await generateKey(); - const encrypted = await encryptField('Hello, World!', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe('Hello, World!'); - }); - - it('empty string', async () => { - const key = await generateKey(); - const encrypted = await encryptField('', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(''); - }); - - it('unicode', async () => { - const key = await generateKey(); - const text = 'こんにちは世界 🌍 مرحبا Ñoño'; - const encrypted = await encryptField(text, key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(text); - }); - - it('large payload', async () => { - const key = await generateKey(); - const text = 'A'.repeat(100_000); - const encrypted = await encryptField(text, key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe(text); - }); -}); - -describe('EncryptedField structure', () => { - it('has correct sentinel fields', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(encrypted.__encrypted).toBe(true); - expect(encrypted.v).toBe(1); - expect(encrypted.alg).toBe('aes-256-gcm'); - expect(encrypted.dekId).toBe('dek_test'); - }); - - it('has correct hex lengths', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex - expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex - expect(encrypted.ct.length).toBeGreaterThan(0); - }); - - it('unique IVs per encryption', async () => { - const key = await generateKey(); - const a = await encryptField('same', key, 'dek_test'); - const b = await encryptField('same', key, 'dek_test'); - expect(a.iv).not.toBe(b.iv); - expect(a.ct).not.toBe(b.ct); - }); -}); - -describe('AAD (Additional Authenticated Data)', () => { - it('roundtrip with AAD', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx'); - const decrypted = await decryptField(encrypted, key, 'user:ctx'); - expect(decrypted).toBe('secret'); - }); - - it('wrong AAD fails', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'correct'); - await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow(); - }); - - it('missing AAD fails', async () => { - const key = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad'); - await expect(decryptField(encrypted, key)).rejects.toThrow(); - }); -}); - -describe('wrong key', () => { - it('decrypt with wrong key fails', async () => { - const key = await generateKey(); - const wrongKey = await generateKey(); - const encrypted = await encryptField('secret', key, 'dek_test'); - await expect(decryptField(encrypted, wrongKey)).rejects.toThrow(); - }); -}); - -describe('keyFromHex / keyToHex', () => { - it('roundtrip', async () => { - const key = await generateKey(); - const hex = await keyToHex(key); - expect(hex.length).toBe(64); // 32 bytes = 64 hex chars - const restored = await keyFromHex(hex); - const encrypted = await encryptField('test', key, 'dek_test'); - const decrypted = await decryptField(encrypted, restored); - expect(decrypted).toBe('test'); - }); - - it('rejects invalid length', async () => { - await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key'); - }); -}); - -describe('deriveKey', () => { - it('derives consistent key from passphrase + salt', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key1 = await deriveKey('my-passphrase', salt, 1000, true); - const key2 = await deriveKey('my-passphrase', salt, 1000, true); - const hex1 = await keyToHex(key1); - const hex2 = await keyToHex(key2); - expect(hex1).toBe(hex2); - }); - - it('different passphrases produce different keys', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key1 = await deriveKey('pass-1', salt, 1000, true); - const key2 = await deriveKey('pass-2', salt, 1000, true); - const hex1 = await keyToHex(key1); - const hex2 = await keyToHex(key2); - expect(hex1).not.toBe(hex2); - }); - - it('derived key can encrypt/decrypt', async () => { - const salt = new Uint8Array(16); - globalThis.crypto.getRandomValues(salt); - const key = await deriveKey('test', salt, 1000, true); - const encrypted = await encryptField('hello', key, 'dek_test'); - const decrypted = await decryptField(encrypted, key); - expect(decrypted).toBe('hello'); - }); -}); - -describe('isEncryptedField', () => { - it('true for valid EncryptedField', async () => { - const key = await generateKey(); - const encrypted = await encryptField('test', key, 'dek_test'); - expect(isEncryptedField(encrypted)).toBe(true); - }); - - it('false for plain string', () => { - expect(isEncryptedField('just a string')).toBe(false); - }); - - it('false for null', () => { - expect(isEncryptedField(null)).toBe(false); - }); - - it('false for incomplete object', () => { - expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false); - }); -}); - -describe('hex utilities', () => { - it('toHex / fromHex roundtrip', () => { - const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]); - const hex = toHex(bytes); - expect(hex).toBe('000fffabcd'); - const restored = fromHex(hex); - expect(restored).toEqual(bytes); - }); - - it('fromHex rejects odd length', () => { - expect(() => fromHex('a')).toThrow('even length'); - }); -}); diff --git a/vendor/bytelyst/client-encrypt/src/aes-gcm.ts b/vendor/bytelyst/client-encrypt/src/aes-gcm.ts deleted file mode 100644 index 764d932..0000000 --- a/vendor/bytelyst/client-encrypt/src/aes-gcm.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API - * - * Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill). - * Produces EncryptedField objects wire-compatible with: - * - @bytelyst/field-encrypt (Node.js server) - * - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto) - */ - -import type { EncryptedField } from './types.js'; -import { toHex, fromHex } from './hex.js'; - -const ALGORITHM = 'AES-GCM'; -const KEY_SIZE_BITS = 256; -const IV_BYTES = 12; -const TAG_BITS = 128; - -/** Get the SubtleCrypto instance (browser or globalThis). */ -function getSubtle(): SubtleCrypto { - if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { - return globalThis.crypto.subtle; - } - throw new Error( - '@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' + - 'Use a polyfill in React Native (e.g., expo-crypto).' - ); -} - -/** Get the crypto object for random bytes. */ -function getCrypto(): Crypto { - if (typeof globalThis !== 'undefined' && globalThis.crypto) { - return globalThis.crypto; - } - throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.'); -} - -/** - * Encrypt a plaintext string with AES-256-GCM using Web Crypto API. - * - * @param plaintext - UTF-8 string to encrypt - * @param key - CryptoKey (AES-GCM, 256-bit) - * @param dekId - DEK identifier stored in the output - * @param aad - Optional additional authenticated data - * @returns EncryptedField with hex-encoded ciphertext, IV, and tag - */ -export async function encryptField( - plaintext: string, - key: CryptoKey, - dekId: string, - aad?: string -): Promise { - const subtle = getSubtle(); - const crypto = getCrypto(); - - const iv = new Uint8Array(IV_BYTES); - crypto.getRandomValues(iv); - - const encoder = new TextEncoder(); - const plaintextBytes = encoder.encode(plaintext); - - const params: AesGcmParams = { - name: ALGORITHM, - iv: iv.buffer as ArrayBuffer, - tagLength: TAG_BITS, - }; - - if (aad) { - params.additionalData = encoder.encode(aad).buffer as ArrayBuffer; - } - - // Web Crypto returns ciphertext || tag concatenated - const ciphertextWithTag = new Uint8Array( - await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer) - ); - - const tagOffset = ciphertextWithTag.length - TAG_BITS / 8; - const ct = ciphertextWithTag.slice(0, tagOffset); - const tag = ciphertextWithTag.slice(tagOffset); - - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: toHex(ct), - iv: toHex(iv), - tag: toHex(tag), - dekId, - }; -} - -/** - * Decrypt an EncryptedField back to plaintext. - * - * @param field - EncryptedField object - * @param key - CryptoKey (must match the key used to encrypt) - * @param aad - Optional AAD (must match the AAD used during encryption) - * @returns Decrypted UTF-8 string - * @throws DOMException if authentication tag verification fails - */ -export async function decryptField( - field: EncryptedField, - key: CryptoKey, - aad?: string -): Promise { - const subtle = getSubtle(); - - const iv = fromHex(field.iv); - const ct = fromHex(field.ct); - const tag = fromHex(field.tag); - - // Web Crypto expects ciphertext || tag concatenated - const ciphertextWithTag = new Uint8Array(ct.length + tag.length); - ciphertextWithTag.set(ct, 0); - ciphertextWithTag.set(tag, ct.length); - - const params: AesGcmParams = { - name: ALGORITHM, - iv: iv.buffer as ArrayBuffer, - tagLength: TAG_BITS, - }; - - if (aad) { - params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer; - } - - const plaintextBytes = new Uint8Array( - await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer) - ); - - return new TextDecoder().decode(plaintextBytes); -} - -/** - * Generate a random AES-256-GCM CryptoKey. - * - * @param extractable - Whether the key material can be exported (default: true). - * Set to `false` for non-extractable keys stored in IndexedDB. - */ -export async function generateKey(extractable = true): Promise { - const subtle = getSubtle(); - return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [ - 'encrypt', - 'decrypt', - ]); -} - -/** - * Import a hex-encoded key string as a CryptoKey. - * - * @param hex - 64 hex chars = 32 bytes - * @param extractable - Whether the imported key can be exported (default: true) - */ -export async function keyFromHex(hex: string, extractable = true): Promise { - const subtle = getSubtle(); - const keyBytes = fromHex(hex); - if (keyBytes.length !== KEY_SIZE_BITS / 8) { - throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`); - } - return subtle.importKey( - 'raw', - keyBytes.buffer as ArrayBuffer, - { name: ALGORITHM, length: KEY_SIZE_BITS }, - extractable, - ['encrypt', 'decrypt'] - ); -} - -/** - * Export a CryptoKey to a hex-encoded string. - * Only works if the key was created with `extractable: true`. - */ -export async function keyToHex(key: CryptoKey): Promise { - const subtle = getSubtle(); - const raw = new Uint8Array(await subtle.exportKey('raw', key)); - return toHex(raw); -} - -/** - * Derive an AES-256 key from a passphrase using PBKDF2. - * - * @param passphrase - User passphrase - * @param salt - Random salt (at least 16 bytes recommended) - * @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023) - * @param extractable - Whether derived key can be exported (default: false) - */ -export async function deriveKey( - passphrase: string, - salt: Uint8Array, - iterations = 600_000, - extractable = false -): Promise { - const subtle = getSubtle(); - const encoder = new TextEncoder(); - - const baseKey = await subtle.importKey( - 'raw', - encoder.encode(passphrase).buffer as ArrayBuffer, - 'PBKDF2', - false, - ['deriveKey'] - ); - - return subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt.buffer as ArrayBuffer, - iterations, - hash: 'SHA-256', - }, - baseKey, - { name: ALGORITHM, length: KEY_SIZE_BITS }, - extractable, - ['encrypt', 'decrypt'] - ); -} diff --git a/vendor/bytelyst/client-encrypt/src/guards.ts b/vendor/bytelyst/client-encrypt/src/guards.ts deleted file mode 100644 index 7f7a5f9..0000000 --- a/vendor/bytelyst/client-encrypt/src/guards.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @bytelyst/client-encrypt — Type guards - * - * Compatible with @bytelyst/field-encrypt isEncryptedField() on the server. - */ - -import type { EncryptedField } from './types.js'; - -/** Check if a value is an EncryptedField object. */ -export function isEncryptedField(value: unknown): value is EncryptedField { - if (typeof value !== 'object' || value === null) return false; - const obj = value as Record; - return ( - obj.__encrypted === true && - obj.v !== undefined && - obj.alg !== undefined && - typeof obj.ct === 'string' && - typeof obj.iv === 'string' && - typeof obj.tag === 'string' && - typeof obj.dekId === 'string' - ); -} diff --git a/vendor/bytelyst/client-encrypt/src/hex.ts b/vendor/bytelyst/client-encrypt/src/hex.ts deleted file mode 100644 index 9caf472..0000000 --- a/vendor/bytelyst/client-encrypt/src/hex.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @bytelyst/client-encrypt — Hex encoding utilities - * - * Converts between Uint8Array and hex strings. - * Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js) - * and BLFieldEncrypt (Swift/Kotlin). - */ - -/** Encode a Uint8Array to a lowercase hex string. */ -export function toHex(bytes: Uint8Array): string { - const parts: string[] = new Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - parts[i] = bytes[i].toString(16).padStart(2, '0'); - } - return parts.join(''); -} - -/** Decode a hex string to a Uint8Array. */ -export function fromHex(hex: string): Uint8Array { - if (hex.length % 2 !== 0) { - throw new Error(`Hex string must have even length, got ${hex.length}`); - } - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; -} diff --git a/vendor/bytelyst/client-encrypt/src/index.ts b/vendor/bytelyst/client-encrypt/src/index.ts deleted file mode 100644 index 951c981..0000000 --- a/vendor/bytelyst/client-encrypt/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @bytelyst/client-encrypt - * - * Client-side AES-256-GCM field encryption using Web Crypto API. - * Works in browsers and React Native (with SubtleCrypto polyfill). - * Wire-compatible with @bytelyst/field-encrypt (server) and - * BLFieldEncrypt (Swift/Kotlin native SDKs). - * - * @example - * ```typescript - * import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt'; - * - * const key = await generateKey(); - * const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes'); - * const plaintext = await decryptField(encrypted, key); - * ``` - */ - -// ── Main API ──────────────────────────────────────── -export { - encryptField, - decryptField, - generateKey, - keyFromHex, - keyToHex, - deriveKey, -} from './aes-gcm.js'; - -// ── Type guards ───────────────────────────────────── -export { isEncryptedField } from './guards.js'; - -// ── Hex utilities ─────────────────────────────────── -export { toHex, fromHex } from './hex.js'; - -// ── Types ─────────────────────────────────────────── -export type { EncryptedField, ClientEncryptContext } from './types.js'; diff --git a/vendor/bytelyst/client-encrypt/src/types.ts b/vendor/bytelyst/client-encrypt/src/types.ts deleted file mode 100644 index 59ea370..0000000 --- a/vendor/bytelyst/client-encrypt/src/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @bytelyst/client-encrypt — Types - * - * Shared type definitions for client-side field encryption. - * Wire-compatible with @bytelyst/field-encrypt (server) and - * BLFieldEncrypt (Swift/Kotlin native SDKs). - */ - -/** Encrypted field stored in Cosmos DB or API responses. */ -export interface EncryptedField { - /** Sentinel — always true for encrypted fields. */ - readonly __encrypted: true; - /** Schema version for future algorithm changes. */ - readonly v: 1; - /** Algorithm identifier. */ - readonly alg: 'aes-256-gcm'; - /** Ciphertext (hex-encoded). */ - readonly ct: string; - /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ - readonly iv: string; - /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ - readonly tag: string; - /** DEK identifier — identifies which key to use for decryption. */ - readonly dekId: string; -} - -/** Options for encrypt/decrypt operations. */ -export interface ClientEncryptContext { - /** Scope for DEK isolation (typically userId). */ - readonly userId: string; - /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ - readonly context: string; -} diff --git a/vendor/bytelyst/client-encrypt/tsconfig.json b/vendor/bytelyst/client-encrypt/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/client-encrypt/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/config/package.json b/vendor/bytelyst/config/package.json deleted file mode 100644 index 433cbda..0000000 --- a/vendor/bytelyst/config/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@bytelyst/config", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./keyvault": { - "import": "./dist/keyvault.js", - "types": "./dist/keyvault.d.ts" - }, - "./product-identity": { - "import": "./dist/product-identity.js", - "types": "./dist/product-identity.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/identity": ">=4.0.0", - "@azure/keyvault-secrets": ">=4.8.0", - "zod": ">=3.20.0" - }, - "peerDependenciesMeta": { - "@azure/identity": { - "optional": true - }, - "@azure/keyvault-secrets": { - "optional": true - } - }, - "devDependencies": { - "@azure/identity": "^4.13.0", - "@azure/keyvault-secrets": "^4.10.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/config/src/__tests__/config.test.ts b/vendor/bytelyst/config/src/__tests__/config.test.ts deleted file mode 100644 index ae64c6c..0000000 --- a/vendor/bytelyst/config/src/__tests__/config.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { - baseEnvSchema, - loadConfig, - loadProductIdentity, - getProductId, - _resetProductIdentity, -} from '../index.js'; - -describe('baseEnvSchema', () => { - it('provides defaults for PORT, HOST, NODE_ENV, COSMOS_DATABASE', () => { - const result = baseEnvSchema.parse({ - SERVICE_NAME: 'test-svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.PORT).toBe(3000); - expect(result.HOST).toBe('0.0.0.0'); - expect(result.NODE_ENV).toBe('development'); - expect(result.COSMOS_DATABASE).toBe('lysnrai'); - }); - - it('rejects missing SERVICE_NAME', () => { - expect(() => - baseEnvSchema.parse({ - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); - - it('rejects missing COSMOS_ENDPOINT', () => { - expect(() => - baseEnvSchema.parse({ - SERVICE_NAME: 'svc', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); - - it('rejects missing COSMOS_KEY', () => { - expect(() => - baseEnvSchema.parse({ - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - }) - ).toThrow(); - }); - - it('coerces PORT from string', () => { - const result = baseEnvSchema.parse({ - PORT: '4003', - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.PORT).toBe(4003); - }); - - it('accepts valid NODE_ENV values', () => { - for (const env of ['development', 'production', 'test']) { - const result = baseEnvSchema.parse({ - NODE_ENV: env, - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }); - expect(result.NODE_ENV).toBe(env); - } - }); - - it('rejects invalid NODE_ENV', () => { - expect(() => - baseEnvSchema.parse({ - NODE_ENV: 'staging', - SERVICE_NAME: 'svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }) - ).toThrow(); - }); -}); - -describe('loadConfig', () => { - const origEnv = process.env; - - beforeEach(() => { - process.env = { - ...origEnv, - SERVICE_NAME: 'test-svc', - COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/', - COSMOS_KEY: 'key==', - }; - }); - - afterEach(() => { - process.env = origEnv; - }); - - it('parses base env without extension', () => { - const config = loadConfig(); - expect(config.SERVICE_NAME).toBe('test-svc'); - expect(config.PORT).toBe(3000); - }); - - it('extends with additional fields', async () => { - process.env.STRIPE_KEY = 'sk_test_123'; - const { z } = await import('zod'); - const config = loadConfig({ STRIPE_KEY: z.string().min(1) }); - expect(config.STRIPE_KEY).toBe('sk_test_123'); - expect(config.SERVICE_NAME).toBe('test-svc'); - }); - - it('throws on missing required extension field', async () => { - const { z } = await import('zod'); - expect(() => loadConfig({ MISSING_FIELD: z.string().min(1) })).toThrow(); - }); -}); - -describe('productIdentity', () => { - beforeEach(() => { - _resetProductIdentity(); - }); - - it('falls back to env vars', () => { - process.env.PRODUCT_ID = 'testprod'; - process.env.DISPLAY_NAME = 'TestProd'; - const identity = loadProductIdentity(); - expect(identity.productId).toBe('testprod'); - expect(identity.displayName).toBe('TestProd'); - delete process.env.PRODUCT_ID; - delete process.env.DISPLAY_NAME; - }); - - it('defaults to lysnrai when no env or file', () => { - delete process.env.PRODUCT_ID; - const identity = loadProductIdentity(); - expect(identity.productId).toBe('lysnrai'); - expect(identity.displayName).toBe('LysnrAI'); - expect(identity.licensePrefix).toBe('LYSNR'); - }); - - it('getProductId returns just the ID', () => { - _resetProductIdentity(); - delete process.env.PRODUCT_ID; - expect(getProductId()).toBe('lysnrai'); - }); - - it('caches identity after first load', () => { - delete process.env.PRODUCT_ID; - const id1 = loadProductIdentity(); - process.env.PRODUCT_ID = 'changed'; - const id2 = loadProductIdentity(); - expect(id1).toBe(id2); // same cached object - delete process.env.PRODUCT_ID; - }); - - it('_resetProductIdentity clears cache', () => { - delete process.env.PRODUCT_ID; - loadProductIdentity(); - _resetProductIdentity(); - process.env.PRODUCT_ID = 'newprod'; - const fresh = loadProductIdentity(); - expect(fresh.productId).toBe('newprod'); - delete process.env.PRODUCT_ID; - }); -}); diff --git a/vendor/bytelyst/config/src/__tests__/keyvault.test.ts b/vendor/bytelyst/config/src/__tests__/keyvault.test.ts deleted file mode 100644 index 659f193..0000000 --- a/vendor/bytelyst/config/src/__tests__/keyvault.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Tests for Azure Key Vault secret resolution. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '../keyvault.js'; -import type { SecretMapping } from '../keyvault.js'; - -// Mock Azure SDK dynamic imports to prevent test timeouts -const { mockGetSecret } = vi.hoisted(() => { - const mockGetSecret = vi.fn(); - return { mockGetSecret }; -}); - -vi.mock('@azure/identity', () => ({ - DefaultAzureCredential: vi.fn(), -})); - -vi.mock('@azure/keyvault-secrets', () => ({ - SecretClient: vi.fn().mockImplementation(() => ({ - getSecret: mockGetSecret, - })), -})); - -describe('resolveKeyVaultSecrets', () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - // Clean env vars used in tests - delete process.env.AZURE_KEYVAULT_URL; - delete process.env.TEST_SECRET_A; - delete process.env.TEST_SECRET_B; - mockGetSecret.mockReset(); - }); - - afterEach(() => { - process.env = { ...originalEnv }; - vi.clearAllMocks(); - }); - - it('skips entirely when AZURE_KEYVAULT_URL is not set', async () => { - const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets); - - // Should not have set the env var (no KV to resolve from) - expect(process.env.TEST_SECRET_A).toBeUndefined(); - }); - - it('skips secrets that already exist in env', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - process.env.TEST_SECRET_A = 'already-set'; - - const secrets: SecretMapping[] = [{ kvName: 'test-secret-a', envVar: 'TEST_SECRET_A' }]; - - // Should not attempt KV call since all secrets are present - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBe('already-set'); - }); - - it('accepts custom vaultUrl via opts', async () => { - mockGetSecret.mockResolvedValue({ value: 'resolved-value' }); - - const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets, { vaultUrl: 'https://kv-test.vault.azure.net' }); - - expect(process.env.TEST_SECRET_A).toBe('resolved-value'); - expect(mockGetSecret).toHaveBeenCalledWith('test-secret'); - }); - - it('handles empty secrets array', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - - await expect(resolveKeyVaultSecrets([])).resolves.not.toThrow(); - }); - - it('resolves multiple missing secrets from Key Vault', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - mockGetSecret - .mockResolvedValueOnce({ value: 'secret-a-val' }) - .mockResolvedValueOnce({ value: 'secret-b-val' }); - - const secrets: SecretMapping[] = [ - { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, - { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, - ]; - - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBe('secret-a-val'); - expect(process.env.TEST_SECRET_B).toBe('secret-b-val'); - }); - - it('warns but does not throw when getSecret fails', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - mockGetSecret.mockRejectedValue(new Error('SecretNotFound')); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const secrets: SecretMapping[] = [{ kvName: 'bad-secret', envVar: 'TEST_SECRET_A' }]; - - await resolveKeyVaultSecrets(secrets); - - expect(process.env.TEST_SECRET_A).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1/1 secrets failed')); - - warnSpy.mockRestore(); - }); - - it('filters to only missing secrets — skips already-present', async () => { - process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net'; - process.env.TEST_SECRET_A = 'present'; - mockGetSecret.mockResolvedValue({ value: 'from-kv' }); - - const secrets: SecretMapping[] = [ - { kvName: 'secret-a', envVar: 'TEST_SECRET_A' }, - { kvName: 'secret-b', envVar: 'TEST_SECRET_B' }, - ]; - - await resolveKeyVaultSecrets(secrets); - - // TEST_SECRET_A should remain unchanged (already present) - expect(process.env.TEST_SECRET_A).toBe('present'); - // TEST_SECRET_B should be resolved from KV - expect(process.env.TEST_SECRET_B).toBe('from-kv'); - // getSecret should only be called for the missing secret - expect(mockGetSecret).toHaveBeenCalledTimes(1); - expect(mockGetSecret).toHaveBeenCalledWith('secret-b'); - }); -}); - -describe('LYSNR_SECRETS', () => { - it('exports all expected secret mappings', () => { - const expectedKeys = [ - 'COSMOS_KEY', - 'COSMOS_ENDPOINT', - 'JWT_SECRET', - 'STRIPE_SECRET_KEY', - 'STRIPE_WEBHOOK_SECRET', - 'BILLING_INTERNAL_KEY', - 'AZURE_BLOB_CONNECTION_STRING', - 'AZURE_BLOB_ACCOUNT_KEY', - 'GEMINI_API_KEY', - 'SEED_SECRET', - 'AZURE_SPEECH_KEY', - 'AZURE_OPENAI_KEY', - 'AZURE_OPENAI_ENDPOINT', - ]; - - for (const key of expectedKeys) { - expect(LYSNR_SECRETS).toHaveProperty(key); - } - }); - - it('each mapping has kvName and envVar', () => { - for (const [key, mapping] of Object.entries(LYSNR_SECRETS)) { - expect(mapping.kvName).toBeDefined(); - expect(typeof mapping.kvName).toBe('string'); - expect(mapping.kvName.length).toBeGreaterThan(0); - - expect(mapping.envVar).toBeDefined(); - expect(typeof mapping.envVar).toBe('string'); - expect(mapping.envVar).toBe(key); - } - }); - - it('kvNames follow lysnr-* naming convention', () => { - for (const mapping of Object.values(LYSNR_SECRETS)) { - expect(mapping.kvName).toMatch(/^lysnr-/); - } - }); - - it('envVars are UPPER_SNAKE_CASE', () => { - for (const mapping of Object.values(LYSNR_SECRETS)) { - expect(mapping.envVar).toMatch(/^[A-Z][A-Z0-9_]*$/); - } - }); -}); diff --git a/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts b/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts deleted file mode 100644 index b1abd84..0000000 --- a/vendor/bytelyst/config/src/__tests__/product-manifest.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { - ProductManifestSchema, - ExtendedProductManifestSchema, - PlatformSchema, - ThemeSchema, - ContainerDefSchema, - FeatureFlagSchema, - BundleIdSchema, - DEFAULT_THEME, - loadProductManifest, - loadProductManifestSync, - resolveTheme, - validateProductManifest, - safeValidateProductManifest, -} from '../index.js'; - -// ── Minimal valid manifest ────────────────────────────────────────────────── - -const MINIMAL = { - productId: 'testprod', - displayName: 'TestProd', -}; - -// ── Full manifest (matches FlowMonk-style) ────────────────────────────────── - -const FULL = { - productId: 'flowmonk', - displayName: 'FlowMonk', - name: 'FlowMonk', - tagline: 'Agent-first planning and execution', - domain: 'flowmonk.app', - backendPort: 4017, - primarySurface: 'web', - mobileCompanion: true, - platforms: ['web', 'ios', 'android'], - bundleIds: { - ios: 'com.saravana.flowmonk', - android: 'com.saravana.flowmonk', - web: 'flowmonk.app', - }, - appStore: { - category: 'Productivity', - subcategory: 'Task Management', - ageRating: '4+', - privacyUrl: 'https://flowmonk.app/privacy', - termsUrl: 'https://flowmonk.app/terms', - supportUrl: 'https://flowmonk.app/support', - }, - cosmos: { - containers: [ - { name: 'zones', partitionKey: '/userId' }, - { name: 'flows', partitionKey: '/userId' }, - { name: 'tasks', partitionKey: '/userId' }, - ], - }, - flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }], - ports: { service: 4017 }, - version: '0.1.0', -}; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('ProductManifestSchema', () => { - it('parses a minimal manifest (just productId + displayName)', () => { - const result = ProductManifestSchema.parse(MINIMAL); - expect(result.productId).toBe('testprod'); - expect(result.displayName).toBe('TestProd'); - expect(result.platforms).toEqual(['web']); // default - expect(result.flags).toEqual([]); // default - }); - - it('parses a full manifest', () => { - const result = ProductManifestSchema.parse(FULL); - expect(result.productId).toBe('flowmonk'); - expect(result.backendPort).toBe(4017); - expect(result.cosmos?.containers).toHaveLength(3); - expect(result.flags).toHaveLength(1); - expect(result.appStore?.category).toBe('Productivity'); - }); - - it('rejects missing productId', () => { - expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow(); - }); - - it('rejects missing displayName', () => { - expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow(); - }); - - it('rejects invalid productId (uppercase)', () => { - expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow( - /lowercase/ - ); - }); - - it('rejects productId starting with number', () => { - expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow(); - }); - - it('allows hyphens in productId', () => { - const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' }); - expect(result.productId).toBe('my-app'); - }); - - it('rejects backendPort below 1024', () => { - expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow(); - }); - - it('rejects backendPort above 65535', () => { - expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow(); - }); - - it('accepts valid backendPort', () => { - const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 }); - expect(result.backendPort).toBe(4016); - }); - - it('parses LysnrAI-style manifest (legacy fields)', () => { - const lysnrai = { - displayName: 'LysnrAI', - productId: 'lysnrai', - licensePrefix: 'LYSNR', - configDirName: '.LysnrAI', - envVarPrefix: 'LYSNR', - bundleIdSuffix: 'LysnrAI', - packageName: 'lysnrai', - }; - const result = ProductManifestSchema.parse(lysnrai); - expect(result.licensePrefix).toBe('LYSNR'); - expect(result.envVarPrefix).toBe('LYSNR'); - }); - - it('parses NoteLett-style manifest (bundleId as object)', () => { - const notelett = { - productId: 'notelett', - displayName: 'NoteLett', - bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, - backendPort: 4016, - appGroup: 'group.com.bytelyst.notelett', - }; - const result = ProductManifestSchema.parse(notelett); - expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }); - expect(result.appGroup).toBe('group.com.bytelyst.notelett'); - }); - - it('parses NomGap-style manifest (bundleId as string)', () => { - const nomgap = { - productId: 'nomgap', - displayName: 'NomGap', - bundleId: 'com.saravana.nomgap', - domain: 'nomgap.app', - }; - const result = ProductManifestSchema.parse(nomgap); - expect(result.bundleId).toBe('com.saravana.nomgap'); - }); - - it('parses ActionTrail-style manifest (no mobile)', () => { - const actiontrail = { - productId: 'actiontrail', - displayName: 'ActionTrail', - tagline: 'AI Activity Oversight', - domain: 'actiontrail.dev', - backendPort: 4018, - primarySurface: 'web', - mobileCompanion: false, - }; - const result = ProductManifestSchema.parse(actiontrail); - expect(result.mobileCompanion).toBe(false); - expect(result.primarySurface).toBe('web'); - }); -}); - -describe('duplicate container validation', () => { - it('rejects duplicate container names', () => { - const manifest = { - ...MINIMAL, - cosmos: { - containers: [ - { name: 'users', partitionKey: '/userId' }, - { name: 'users', partitionKey: '/email' }, - ], - }, - }; - expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/); - }); - - it('allows unique container names', () => { - const manifest = { - ...MINIMAL, - cosmos: { - containers: [ - { name: 'users', partitionKey: '/userId' }, - { name: 'sessions', partitionKey: '/userId' }, - ], - }, - }; - const result = ProductManifestSchema.parse(manifest); - expect(result.cosmos?.containers).toHaveLength(2); - }); - - it('allows single container (no duplicate check needed)', () => { - const manifest = { - ...MINIMAL, - cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] }, - }; - const result = ProductManifestSchema.parse(manifest); - expect(result.cosmos?.containers).toHaveLength(1); - }); -}); - -describe('ExtendedProductManifestSchema', () => { - it('allows unknown keys (passthrough)', () => { - const result = ExtendedProductManifestSchema.parse({ - ...MINIMAL, - customField: 'hello', - nestedCustom: { x: 1 }, - }); - expect((result as Record).customField).toBe('hello'); - }); -}); - -describe('PlatformSchema', () => { - it('accepts valid platforms', () => { - for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) { - expect(PlatformSchema.parse(p)).toBe(p); - } - }); - - it('rejects invalid platform', () => { - expect(() => PlatformSchema.parse('windows')).toThrow(); - }); -}); - -describe('ThemeSchema', () => { - it('validates hex colors', () => { - const result = ThemeSchema.parse(DEFAULT_THEME); - expect(result.primary).toBe('#5AE68C'); - }); - - it('rejects non-hex colors', () => { - expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/); - }); - - it('rejects 3-digit hex', () => { - expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow(); - }); -}); - -describe('ContainerDefSchema', () => { - it('parses valid container', () => { - const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' }); - expect(result.name).toBe('users'); - }); - - it('accepts optional ttlSeconds and uniqueKeys', () => { - const result = ContainerDefSchema.parse({ - name: 'sessions', - partitionKey: '/userId', - ttlSeconds: 86400, - uniqueKeys: ['/email'], - }); - expect(result.ttlSeconds).toBe(86400); - expect(result.uniqueKeys).toEqual(['/email']); - }); - - it('rejects empty name', () => { - expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow(); - }); - - it('rejects negative ttlSeconds', () => { - expect(() => - ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 }) - ).toThrow(); - }); -}); - -describe('FeatureFlagSchema', () => { - it('accepts boolean default', () => { - const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false }); - expect(result.defaultValue).toBe(false); - }); - - it('accepts string default', () => { - const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' }); - expect(result.defaultValue).toBe('free'); - }); - - it('accepts number default', () => { - const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 }); - expect(result.defaultValue).toBe(100); - }); - - it('rejects empty key', () => { - expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow(); - }); -}); - -describe('BundleIdSchema', () => { - it('accepts string bundle ID', () => { - expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app'); - }); - - it('accepts per-platform bundle IDs', () => { - const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' }); - expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' }); - }); - - it('rejects empty string', () => { - expect(() => BundleIdSchema.parse('')).toThrow(); - }); -}); - -describe('resolveTheme', () => { - it('returns defaults when no theme specified', () => { - const manifest = ProductManifestSchema.parse(MINIMAL); - const theme = resolveTheme(manifest); - expect(theme).toEqual(DEFAULT_THEME); - }); - - it('merges partial theme with defaults', () => { - const manifest = ProductManifestSchema.parse({ - ...MINIMAL, - theme: { primary: '#FF0000' }, - }); - const theme = resolveTheme(manifest); - expect(theme.primary).toBe('#FF0000'); - expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved - }); -}); - -describe('validateProductManifest', () => { - it('validates a valid object', () => { - const result = validateProductManifest(MINIMAL); - expect(result.productId).toBe('testprod'); - }); - - it('throws on invalid object', () => { - expect(() => validateProductManifest({ bad: true })).toThrow(); - }); -}); - -describe('safeValidateProductManifest', () => { - it('returns manifest on valid input', () => { - const result = safeValidateProductManifest(MINIMAL); - expect(result).not.toBeNull(); - expect(result!.productId).toBe('testprod'); - }); - - it('returns null on invalid input', () => { - const result = safeValidateProductManifest({ bad: true }); - expect(result).toBeNull(); - }); -}); - -describe('loadProductManifest / loadProductManifestSync', () => { - const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`); - const validPath = join(tmpDir, 'valid.json'); - const invalidPath = join(tmpDir, 'invalid.json'); - - beforeEach(() => { - mkdirSync(tmpDir, { recursive: true }); - writeFileSync(validPath, JSON.stringify(FULL)); - writeFileSync(invalidPath, JSON.stringify({ bad: true })); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('loads and validates from file (async)', async () => { - const result = await loadProductManifest(validPath); - expect(result.productId).toBe('flowmonk'); - expect(result.cosmos?.containers).toHaveLength(3); - }); - - it('loads and validates from file (sync)', () => { - const result = loadProductManifestSync(validPath); - expect(result.productId).toBe('flowmonk'); - }); - - it('throws on invalid file (async)', async () => { - await expect(loadProductManifest(invalidPath)).rejects.toThrow(); - }); - - it('throws on invalid file (sync)', () => { - expect(() => loadProductManifestSync(invalidPath)).toThrow(); - }); - - it('throws on missing file (async)', async () => { - await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow(); - }); - - it('throws on missing file (sync)', () => { - expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow(); - }); -}); - -describe('real-world product.json files parse cleanly', () => { - it('parses LysnrAI product.json', () => { - const json = { - displayName: 'LysnrAI', - productId: 'lysnrai', - licensePrefix: 'LYSNR', - configDirName: '.LysnrAI', - envVarPrefix: 'LYSNR', - bundleIdSuffix: 'LysnrAI', - packageName: 'lysnrai', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses NomGap product.json', () => { - const json = { - productId: 'nomgap', - displayName: 'NomGap', - bundleId: 'com.saravana.nomgap', - domain: 'nomgap.app', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses NoteLett product.json', () => { - const json = { - productId: 'notelett', - displayName: 'NoteLett', - licensePrefix: 'NOTELETT', - configDirName: '.NoteLett', - envVarPrefix: 'NOTELETT', - bundleIdSuffix: 'notelett', - packageName: 'notelett', - domain: 'notelett.app', - bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, - appGroup: 'group.com.bytelyst.notelett', - backendPort: 4016, - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); - - it('parses FlowMonk product.json', () => { - expect(() => ProductManifestSchema.parse(FULL)).not.toThrow(); - }); - - it('parses ActionTrail product.json', () => { - const json = { - productId: 'actiontrail', - displayName: 'ActionTrail', - tagline: 'AI Activity Oversight', - domain: 'actiontrail.dev', - backendPort: 4018, - primarySurface: 'web', - mobileCompanion: false, - bundleIds: { web: 'actiontrail.dev' }, - appStore: { - category: 'Developer Tools', - subcategory: 'AI Monitoring', - privacyUrl: 'https://actiontrail.dev/privacy', - termsUrl: 'https://actiontrail.dev/terms', - supportUrl: 'https://actiontrail.dev/support', - }, - version: '0.1.0', - }; - expect(() => ProductManifestSchema.parse(json)).not.toThrow(); - }); -}); diff --git a/vendor/bytelyst/config/src/base-schema.ts b/vendor/bytelyst/config/src/base-schema.ts deleted file mode 100644 index 89bf02a..0000000 --- a/vendor/bytelyst/config/src/base-schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Base environment schema shared by all Fastify microservices. - * Each service extends this with its own fields via loadConfig(). - */ - -import { z } from 'zod'; - -export const baseEnvSchema = z.object({ - PORT: z.coerce.number().default(3000), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string(), - COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'), - COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'), - COSMOS_DATABASE: z.string().default('lysnrai'), -}); - -export type BaseEnv = z.infer; diff --git a/vendor/bytelyst/config/src/index.ts b/vendor/bytelyst/config/src/index.ts deleted file mode 100644 index 1184ecb..0000000 --- a/vendor/bytelyst/config/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export { baseEnvSchema, type BaseEnv } from './base-schema.js'; -export { loadConfig } from './loader.js'; -export { - loadProductIdentity, - getProductId, - _resetProductIdentity, - type ProductIdentity, -} from './product-identity.js'; -export { - resolveSecrets, - resolveKeyVaultSecrets, - LYSNR_SECRETS, - type SecretMapping, - type SecretsProviderType, -} from './keyvault.js'; -export { - ProductManifestSchema, - PlatformSchema, - ThemeSchema, - ContainerDefSchema, - FeatureFlagSchema, - PortConfigSchema, - BundleIdSchema, - AppStoreSchema, - ExtendedProductManifestSchema, - DEFAULT_THEME, - loadProductManifest, - loadProductManifestSync, - resolveTheme, - validateProductManifest, - safeValidateProductManifest, - type ProductManifest, - type Platform, - type Theme, - type ContainerDef, - type FeatureFlag, - type PortConfig, -} from './product-manifest.js'; diff --git a/vendor/bytelyst/config/src/keyvault.ts b/vendor/bytelyst/config/src/keyvault.ts deleted file mode 100644 index 18db0c9..0000000 --- a/vendor/bytelyst/config/src/keyvault.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Cloud-agnostic secret resolution for Node.js services + dashboards. - * - * Call resolveSecrets() BEFORE Zod config parsing to populate - * process.env from a secrets provider. Falls back gracefully when unavailable. - * - * Provider selection via SECRETS_PROVIDER env var: - * - 'azure-keyvault' (default if AZURE_KEYVAULT_URL is set) — Azure Key Vault - * - 'env' (default if no vault URL) — do nothing, use .env values as-is - * - * Backward compatible: resolveKeyVaultSecrets() still works identically. - */ - -export type SecretsProviderType = 'azure-keyvault' | 'env'; - -export interface SecretMapping { - /** Provider-specific secret name (e.g. 'lysnr-cosmos-key' for AKV) */ - kvName: string; - /** Environment variable name to populate (e.g. 'COSMOS_KEY') */ - envVar: string; -} - -/** - * Resolve which secrets provider to use. - */ -function resolveSecretsProvider(): SecretsProviderType { - const explicit = (process.env.SECRETS_PROVIDER || '').toLowerCase(); - if (explicit === 'azure-keyvault' || explicit === 'azure') return 'azure-keyvault'; - if (explicit === 'env') return 'env'; - - // Auto-detect: use AKV if AZURE_KEYVAULT_URL is set - if (process.env.AZURE_KEYVAULT_URL) return 'azure-keyvault'; - return 'env'; -} - -/** - * Cloud-agnostic secret resolution into process.env. - * - * - Only fetches secrets whose env var is empty/unset (env takes precedence). - * - Skips entirely if provider is 'env' or no vault is configured. - * - Logs warnings but does NOT throw — services fall back to .env values. - * - * @param secrets - Array of {kvName, envVar} mappings - * @param opts - Optional overrides - */ -export async function resolveSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string; provider?: SecretsProviderType } -): Promise { - const provider = opts?.provider ?? resolveSecretsProvider(); - - if (provider === 'env') return; // Nothing to resolve — use env vars as-is - - if (provider === 'azure-keyvault') { - return resolveAzureKeyVaultSecrets(secrets, opts); - } -} - -/** - * Resolve secrets from Azure Key Vault into process.env. - * @deprecated Use resolveSecrets() instead — this is kept for backward compatibility. - */ -export async function resolveKeyVaultSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string } -): Promise { - return resolveAzureKeyVaultSecrets(secrets, opts); -} - -/** - * Azure Key Vault implementation. - */ -async function resolveAzureKeyVaultSecrets( - secrets: SecretMapping[], - opts?: { vaultUrl?: string } -): Promise { - const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; - if (!vaultUrl) return; // No KV configured — use env vars as-is - - // Filter to only secrets that are missing from env - const missing = secrets.filter(s => !process.env[s.envVar]); - if (missing.length === 0) return; // All secrets already in env - - try { - const { DefaultAzureCredential } = await import('@azure/identity'); - const { SecretClient } = await import('@azure/keyvault-secrets'); - - const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); - - const results = await Promise.allSettled( - missing.map(async s => { - const secret = await client.getSecret(s.kvName); - if (secret.value) { - process.env[s.envVar] = secret.value; - } - }) - ); - - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. - console.warn( - `[secrets] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars` - ); - } - } catch { - // eslint-disable-next-line no-console -- Startup secret-resolution diagnostics must remain visible before app loggers exist. - console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); - } -} - -/** - * Standard secret mappings used across all LysnrAI services. - * Services pick the subset they need. - */ -export const LYSNR_SECRETS = { - COSMOS_KEY: { kvName: 'lysnr-cosmos-key', envVar: 'COSMOS_KEY' }, - COSMOS_ENDPOINT: { kvName: 'lysnr-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, - JWT_SECRET: { kvName: 'lysnr-jwt-secret', envVar: 'JWT_SECRET' }, - STRIPE_SECRET_KEY: { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, - STRIPE_WEBHOOK_SECRET: { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, - BILLING_INTERNAL_KEY: { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, - AZURE_BLOB_CONNECTION_STRING: { - kvName: 'lysnr-blob-connection-string', - envVar: 'AZURE_BLOB_CONNECTION_STRING', - }, - AZURE_BLOB_ACCOUNT_KEY: { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, - GEMINI_API_KEY: { kvName: 'lysnr-gemini-api-key', envVar: 'GEMINI_API_KEY' }, - SEED_SECRET: { kvName: 'lysnr-seed-secret', envVar: 'SEED_SECRET' }, - AZURE_SPEECH_KEY: { kvName: 'lysnr-azure-speech-key', envVar: 'AZURE_SPEECH_KEY' }, - AZURE_OPENAI_KEY: { kvName: 'lysnr-azure-openai-key', envVar: 'AZURE_OPENAI_KEY' }, - AZURE_OPENAI_ENDPOINT: { - kvName: 'lysnr-azure-openai-endpoint', - envVar: 'AZURE_OPENAI_ENDPOINT', - }, -} as const satisfies Record; diff --git a/vendor/bytelyst/config/src/loader.ts b/vendor/bytelyst/config/src/loader.ts deleted file mode 100644 index 3ede866..0000000 --- a/vendor/bytelyst/config/src/loader.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Config loader — parses process.env against the base schema + any extensions. - */ - -import { type ZodRawShape, z } from 'zod'; -import { baseEnvSchema } from './base-schema.js'; - -/** - * Load and validate environment configuration. - * - * @param extension - Additional Zod fields specific to this service - * @returns Parsed and validated config object - * - * @example - * ```ts - * const config = loadConfig({ - * STRIPE_SECRET_KEY: z.string().min(1), - * BILLING_INTERNAL_KEY: z.string().optional(), - * }); - * ``` - */ -export function loadConfig(extension?: T) { - const schema = extension ? baseEnvSchema.extend(extension) : baseEnvSchema; - return schema.parse(process.env) as z.infer & - (T extends ZodRawShape ? z.infer> : Record); -} diff --git a/vendor/bytelyst/config/src/product-identity.ts b/vendor/bytelyst/config/src/product-identity.ts deleted file mode 100644 index 1b961cc..0000000 --- a/vendor/bytelyst/config/src/product-identity.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Product identity — reads from a product.json file or falls back to env vars. - * Eliminates the need for hardcoded product-config.ts files in every service. - */ - -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -export interface ProductIdentity { - productId: string; - displayName: string; - licensePrefix: string; - configDirName: string; - envVarPrefix: string; - bundleIdSuffix: string; - packageName: string; -} - -let _cached: ProductIdentity | null = null; - -/** - * Load product identity from a JSON file or environment variables. - * - * @param jsonPath - Path to product.json (optional, tries common locations) - * @returns Product identity object - */ -export function loadProductIdentity(jsonPath?: string): ProductIdentity { - if (_cached) return _cached; - - // Try loading from file - const paths = jsonPath - ? [jsonPath] - : [ - resolve('shared/product.json'), - resolve('../shared/product.json'), - resolve('../../shared/product.json'), - ]; - - for (const p of paths) { - try { - const raw = readFileSync(p, 'utf-8'); - _cached = JSON.parse(raw) as ProductIdentity; - return _cached; - } catch { - // Try next path - } - } - - // Fallback to env vars / defaults - _cached = { - productId: process.env.PRODUCT_ID || 'lysnrai', - displayName: process.env.DISPLAY_NAME || 'LysnrAI', - licensePrefix: process.env.LICENSE_PREFIX || 'LYSNR', - configDirName: process.env.CONFIG_DIR_NAME || '.LysnrAI', - envVarPrefix: process.env.ENV_VAR_PREFIX || 'LYSNR', - bundleIdSuffix: process.env.BUNDLE_ID_SUFFIX || 'LysnrAI', - packageName: process.env.PACKAGE_NAME || 'lysnrai', - }; - return _cached; -} - -/** - * Convenience: get just the product ID string. - */ -export function getProductId(): string { - return loadProductIdentity().productId; -} - -/** - * Reset the cache (useful for testing). - * @internal - */ -export function _resetProductIdentity(): void { - _cached = null; -} diff --git a/vendor/bytelyst/config/src/product-manifest.ts b/vendor/bytelyst/config/src/product-manifest.ts deleted file mode 100644 index 3598204..0000000 --- a/vendor/bytelyst/config/src/product-manifest.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * Product Manifest Specification - * - * Defines the JSON schema for product.json files that capture everything - * about a ByteLyst product — identity, theme, features, containers, flags, ports. - * - * @module @bytelyst/config/product-manifest - */ - -import { readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { z } from 'zod'; - -/** - * Platform identifiers - */ -export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch', 'mac']); - -/** - * Theme color token - */ -export const ColorTokenSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, { - message: 'Color must be a hex value like #5AE68C', -}); - -/** - * Theme specification - */ -export const ThemeSchema = z.object({ - primary: ColorTokenSchema, - secondary: ColorTokenSchema, - accent: ColorTokenSchema, - background: ColorTokenSchema, - surface: ColorTokenSchema, - text: ColorTokenSchema, - error: ColorTokenSchema, - warning: ColorTokenSchema, - success: ColorTokenSchema, -}); - -/** - * Cosmos container definition - */ -export const ContainerDefSchema = z.object({ - name: z.string().min(1), - partitionKey: z.string().min(1), - ttlSeconds: z.number().positive().optional(), - uniqueKeys: z.array(z.string()).optional(), -}); - -/** - * Feature flag default - */ -export const FeatureFlagSchema = z.object({ - key: z.string().min(1), - defaultValue: z.union([z.boolean(), z.string(), z.number()]), - description: z.string().optional(), -}); - -/** - * Port configuration - */ -export const PortConfigSchema = z.object({ - service: z.number().optional(), - dashboard: z.number().optional(), - web: z.number().optional(), -}); - -/** - * Bundle ID — either a single reverse-DNS string or per-platform object - */ -export const BundleIdSchema = z.union([ - z.string().min(1), - z.object({ - ios: z.string().optional(), - android: z.string().optional(), - web: z.string().optional(), - }), -]); - -/** - * App store metadata (optional) - */ -export const AppStoreSchema = z - .object({ - category: z.string().optional(), - subcategory: z.string().optional(), - ageRating: z.string().optional(), - privacyUrl: z.string().url().optional(), - termsUrl: z.string().url().optional(), - supportUrl: z.string().url().optional(), - }) - .optional(); - -/** - * Product manifest schema (Zod) - * - * Designed to accommodate the real-world variety of product.json files - * across the ByteLyst ecosystem. All products use `productId` as the - * primary identifier. - * - * Example product.json: - * ```json - * { - * "productId": "lysnrai", - * "displayName": "LysnrAI", - * "bundleId": "com.saravana.lysnrai", - * "domain": "lysnrai.app", - * "description": "Voice-to-text dictation platform", - * "backendPort": 4015, - * "platforms": ["web", "ios", "android", "desktop"], - * "cosmos": { - * "containers": [ - * { "name": "transcripts", "partitionKey": "/userId" }, - * { "name": "sessions", "partitionKey": "/userId" } - * ] - * }, - * "ports": { - * "service": 4015, - * "dashboard": 3002 - * } - * } - * ``` - */ -const BaseManifestSchema = z.object({ - // Identity (productId required, rest optional for minimal manifests) - productId: z.string().regex(/^[a-z][a-z0-9-]*$/, { - message: 'Product ID must be lowercase alphanumeric/hyphens, starting with letter', - }), - displayName: z.string().min(1).max(50), - - // Optional identity fields - name: z.string().min(1).max(50).optional(), - bundleId: BundleIdSchema.optional(), - domain: z.string().optional(), - tagline: z.string().max(200).optional(), - description: z.string().max(500).optional(), - version: z - .string() - .regex(/^\d+\.\d+\.\d+$/) - .optional(), - - // Platforms (defaults to web) - platforms: z.array(PlatformSchema).default(['web']), - primarySurface: PlatformSchema.optional(), - mobileCompanion: z.boolean().optional(), - - // Backend port (convenience — also available in ports.service) - backendPort: z.number().min(1024).max(65535).optional(), - - // Legacy identity fields from older product.json files - licensePrefix: z.string().optional(), - configDirName: z.string().optional(), - envVarPrefix: z.string().optional(), - bundleIdSuffix: z.string().optional(), - packageName: z.string().optional(), - appGroup: z.string().optional(), - - // Per-platform bundle IDs (alternative to bundleId) - bundleIds: z.record(z.string(), z.string()).optional(), - - // App store metadata - appStore: AppStoreSchema, - - // Theming (optional, uses defaults if not specified) - theme: ThemeSchema.partial().optional(), - - // Feature map (key → boolean/string/number) - features: z.record(z.string(), z.boolean().or(z.string()).or(z.number())).optional(), - - // Cosmos containers - cosmos: z - .object({ - containers: z.array(ContainerDefSchema).default([]), - }) - .optional(), - - // Feature flags with defaults - flags: z.array(FeatureFlagSchema).default([]), - - // Port configuration - ports: PortConfigSchema.optional(), - - // Agent/AI fields (used by FlowMonk, ActionTrail) - backendAuthority: z.string().optional(), - planningEngine: z.string().optional(), - aiRole: z.array(z.string()).optional(), -}); - -export const ProductManifestSchema = BaseManifestSchema.superRefine((data, ctx) => { - // Validate no duplicate container names - const containers = data.cosmos?.containers; - if (containers && containers.length > 1) { - const names = containers.map(c => c.name); - const seen = new Set(); - for (let i = 0; i < names.length; i++) { - if (seen.has(names[i])) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Duplicate container name: ${names[i]}`, - path: ['cosmos', 'containers', i, 'name'], - }); - } - seen.add(names[i]); - } - } -}); - -/** - * Extended manifest that allows additional unknown keys. - * Use this when you need to access custom fields not in the schema. - */ -export const ExtendedProductManifestSchema = BaseManifestSchema.passthrough(); - -/** - * Inferred TypeScript type for ProductManifest - */ -export type ProductManifest = z.infer; -export type Platform = z.infer; -export type Theme = z.infer; -export type ContainerDef = z.infer; -export type FeatureFlag = z.infer; -export type PortConfig = z.infer; - -/** - * Default theme colors (ByteLyst brand palette) - */ -export const DEFAULT_THEME: Theme = { - primary: '#5AE68C', - secondary: '#5A8CFF', - accent: '#2EE6D6', - background: '#06070A', - surface: '#121725', - text: '#EFF4FF', - error: '#FF6E6E', - warning: '#F59E0B', - success: '#34D399', -}; - -/** - * Load and validate a product manifest from a file path - * - * @param path Path to product.json file - * @returns Validated ProductManifest - * @throws ZodError if validation fails - * - * @example - * ```ts - * const manifest = await loadProductManifest('./product.json'); - * console.log(manifest.productId); // 'lysnrai' - * ``` - */ -export async function loadProductManifest(path: string): Promise { - const content = await readFile(path, 'utf-8'); - const json = JSON.parse(content); - return ProductManifestSchema.parse(json); -} - -/** - * Synchronous version of loadProductManifest (for startup use) - * - * @param path Path to product.json file - * @returns Validated ProductManifest - * @throws ZodError if validation fails - */ -export function loadProductManifestSync(path: string): ProductManifest { - const content = readFileSync(path, 'utf-8'); - const json = JSON.parse(content); - return ProductManifestSchema.parse(json); -} - -/** - * Merge manifest theme with defaults - * - * @param manifest Product manifest - * @returns Complete theme with defaults filled in - */ -export function resolveTheme(manifest: ProductManifest): Theme { - return { - ...DEFAULT_THEME, - ...manifest.theme, - }; -} - -/** - * Validate a product manifest object without loading from file - * - * @param json Parsed JSON object - * @returns Validated ProductManifest - * @throws ZodError if validation fails - */ -export function validateProductManifest(json: unknown): ProductManifest { - return ProductManifestSchema.parse(json); -} - -/** - * Safe validation that returns null on failure - * - * @param json Parsed JSON object - * @returns Validated ProductManifest or null - */ -export function safeValidateProductManifest(json: unknown): ProductManifest | null { - const result = ProductManifestSchema.safeParse(json); - return result.success ? result.data : null; -} diff --git a/vendor/bytelyst/config/tsconfig.json b/vendor/bytelyst/config/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/config/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/cosmos/package.json b/vendor/bytelyst/cosmos/package.json deleted file mode 100644 index 838e959..0000000 --- a/vendor/bytelyst/cosmos/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/cosmos", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/cosmos": ">=4.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts b/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts deleted file mode 100644 index 46107e5..0000000 --- a/vendor/bytelyst/cosmos/src/__tests__/cosmos.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; - -// Must hoist mocks so they're available when vi.mock factory runs -const { mockDatabase, mockDatabases, MockCosmosClient } = vi.hoisted(() => { - const mockContainer = { id: 'test-container' }; - const mockDatabase = { - container: vi.fn(() => mockContainer), - containers: { createIfNotExists: vi.fn() }, - }; - const mockDatabases = { createIfNotExists: vi.fn(() => ({ database: mockDatabase })) }; - const MockCosmosClient = vi.fn(() => ({ - database: vi.fn(() => mockDatabase), - databases: mockDatabases, - })); - return { mockDatabase, mockDatabases, MockCosmosClient }; -}); - -vi.mock('@azure/cosmos', () => ({ - CosmosClient: MockCosmosClient, - PartitionKeyDefinition: class {}, -})); - -import { - getCosmosClient, - getDatabase, - getContainer, - _resetClient, - registerContainers, - getRegisteredContainer, - initializeAllContainers, - _resetRegistry, -} from '../index.js'; - -describe('cosmos client', () => { - beforeEach(() => { - _resetClient(); - _resetRegistry(); - MockCosmosClient.mockClear(); - process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; - process.env.COSMOS_KEY = 'test-key=='; - process.env.COSMOS_DATABASE = 'testdb'; - }); - - afterEach(() => { - delete process.env.COSMOS_ENDPOINT; - delete process.env.COSMOS_KEY; - delete process.env.COSMOS_DATABASE; - }); - - it('getCosmosClient creates singleton', () => { - const c1 = getCosmosClient(); - const c2 = getCosmosClient(); - expect(c1).toBe(c2); - expect(MockCosmosClient).toHaveBeenCalledTimes(1); - expect(MockCosmosClient).toHaveBeenCalledWith({ - endpoint: 'https://test.documents.azure.com:443/', - key: 'test-key==', - }); - }); - - it('getCosmosClient throws without COSMOS_ENDPOINT', () => { - delete process.env.COSMOS_ENDPOINT; - expect(() => getCosmosClient()).toThrow('COSMOS_ENDPOINT is required'); - }); - - it('getCosmosClient throws without COSMOS_KEY', () => { - delete process.env.COSMOS_KEY; - expect(() => getCosmosClient()).toThrow('COSMOS_KEY is required'); - }); - - it('getDatabase uses COSMOS_DATABASE env var', () => { - const db = getDatabase(); - expect(db).toBeDefined(); - }); - - it('getDatabase defaults to lysnrai', () => { - _resetClient(); - delete process.env.COSMOS_DATABASE; - getDatabase(); - // Client was called, database accessed - expect(MockCosmosClient).toHaveBeenCalled(); - }); - - it('getContainer returns container by name', () => { - const c = getContainer('users'); - expect(c).toBeDefined(); - }); - - it('_resetClient clears singleton', () => { - getCosmosClient(); - expect(MockCosmosClient).toHaveBeenCalledTimes(1); - _resetClient(); - getCosmosClient(); - expect(MockCosmosClient).toHaveBeenCalledTimes(2); - }); -}); - -describe('container registry', () => { - beforeEach(() => { - _resetClient(); - _resetRegistry(); - MockCosmosClient.mockClear(); - process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; - process.env.COSMOS_KEY = 'test-key=='; - process.env.COSMOS_DATABASE = 'testdb'; - }); - - afterEach(() => { - delete process.env.COSMOS_ENDPOINT; - delete process.env.COSMOS_KEY; - delete process.env.COSMOS_DATABASE; - }); - - it('registerContainers stores definitions', () => { - registerContainers({ - users: { partitionKeyPath: '/productId' }, - tokens: { partitionKeyPath: '/userId' }, - }); - // Should not throw - const c = getRegisteredContainer('users'); - expect(c).toBeDefined(); - }); - - it('getRegisteredContainer throws for unknown name', () => { - expect(() => getRegisteredContainer('nope')).toThrow("Unknown container 'nope'"); - }); - - it('getRegisteredContainer caches container instances', () => { - registerContainers({ items: { partitionKeyPath: '/id' } }); - const c1 = getRegisteredContainer('items'); - const c2 = getRegisteredContainer('items'); - expect(c1).toBe(c2); - }); - - it('initializeAllContainers creates database and containers', async () => { - registerContainers({ - users: { partitionKeyPath: '/productId' }, - audit: { partitionKeyPath: '/productId', defaultTtl: 86400 }, - }); - - await initializeAllContainers(); - - expect(mockDatabases.createIfNotExists).toHaveBeenCalledWith({ id: 'testdb' }); - expect(mockDatabase.containers.createIfNotExists).toHaveBeenCalledTimes(2); - }); - - it('_resetRegistry clears all', () => { - registerContainers({ x: { partitionKeyPath: '/id' } }); - _resetRegistry(); - expect(() => getRegisteredContainer('x')).toThrow("Unknown container 'x'"); - }); -}); diff --git a/vendor/bytelyst/cosmos/src/client.ts b/vendor/bytelyst/cosmos/src/client.ts deleted file mode 100644 index fdd68e1..0000000 --- a/vendor/bytelyst/cosmos/src/client.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Azure Cosmos DB client singleton. - * - * Reads COSMOS_ENDPOINT, COSMOS_KEY, and COSMOS_DATABASE from process.env. - * Provides getCosmosClient(), getDatabase(), and getContainer() for simple usage. - */ - -import { Container, CosmosClient, Database } from '@azure/cosmos'; - -let _client: CosmosClient | null = null; -let _database: Database | null = null; - -function getEnvOrThrow(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Environment variable ${name} is required`); - } - return value; -} - -export function getCosmosClient(): CosmosClient { - if (!_client) { - _client = new CosmosClient({ - endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), - key: getEnvOrThrow('COSMOS_KEY'), - }); - } - return _client; -} - -export function getDatabase(): Database { - if (!_database) { - const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; - _database = getCosmosClient().database(dbId); - } - return _database; -} - -/** - * Get a container by name. Uses the singleton database. - * For simple services that don't need a container registry. - */ -export function getContainer(name: string): Container { - return getDatabase().container(name); -} - -/** - * Reset the singleton (useful for testing). - * @internal - */ -export function _resetClient(): void { - _client = null; - _database = null; -} diff --git a/vendor/bytelyst/cosmos/src/containers.ts b/vendor/bytelyst/cosmos/src/containers.ts deleted file mode 100644 index acd009d..0000000 --- a/vendor/bytelyst/cosmos/src/containers.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Container registry for dashboards that need partition key validation - * and createIfNotExists support. - */ - -import { Container, PartitionKeyDefinition, type Database } from '@azure/cosmos'; -import { getCosmosClient, getDatabase } from './client.js'; -import type { ContainerConfig } from './types.js'; - -const _registry: Map = new Map(); -const _containerCache: Map = new Map(); - -/** - * Register containers with their partition key configuration. - * Call once at app startup before any getRegisteredContainer() calls. - */ -export function registerContainers(definitions: Record): void { - for (const [name, config] of Object.entries(definitions)) { - _registry.set(name, config); - } -} - -/** - * Get a container that was previously registered. - * Throws if the container name is unknown. - */ -export function getRegisteredContainer(name: string): Container { - if (!_registry.has(name)) { - throw new Error(`Unknown container '${name}'. Valid: ${[..._registry.keys()].join(', ')}`); - } - - let container = _containerCache.get(name); - if (!container) { - container = getDatabase().container(name); - _containerCache.set(name, container); - } - return container; -} - -/** - * Create all registered containers if they don't exist. - * Call from a seed script or on first deploy. - */ -export async function initializeAllContainers(): Promise { - const client = getCosmosClient(); - const dbId = process.env.COSMOS_DATABASE || 'lysnrai'; - const database = await createDatabaseSafe(client, dbId); - - for (const [name, config] of _registry.entries()) { - await createContainerSafe(database, name, config); - } -} - -function sleep(ms: number): Promise { - return new Promise(resolve => globalThis.setTimeout(resolve, ms)); -} - -function isCosmosConflict(err: unknown): boolean { - const e = err as { code?: number; statusCode?: number; message?: string } | null; - if (!e) return false; - if (e.code === 409 || e.statusCode === 409) return true; - return (e.message || '').toLowerCase().includes('already exists'); -} - -function isCosmosNotFound(err: unknown): boolean { - const e = err as { code?: number; statusCode?: number; message?: string } | null; - if (!e) return false; - if (e.code === 404 || e.statusCode === 404) return true; - return (e.message || '').toLowerCase().includes('not found'); -} - -async function createDatabaseSafe( - client: ReturnType, - dbId: string -): Promise { - try { - const { database } = await client.databases.createIfNotExists({ id: dbId }); - return database; - } catch (err) { - // createIfNotExists is not atomic; concurrent create can race and throw a conflict. - if (isCosmosConflict(err)) return client.database(dbId); - throw err; - } -} - -async function createContainerSafe( - database: Database, - name: string, - config: ContainerConfig -): Promise { - const payload = { - id: name, - partitionKey: { - paths: [config.partitionKeyPath], - kind: 'Hash', - } as PartitionKeyDefinition, - ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), - }; - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await database.containers.createIfNotExists(payload); - return; - } catch (err) { - if (isCosmosConflict(err)) return; // Container was created by another process. - - // Sometimes the database/container metadata isn't immediately visible after creation. - if (isCosmosNotFound(err) && attempt < 2) { - await sleep(250 * (attempt + 1)); - continue; - } - - throw err; - } - } -} - -/** - * Reset the registry (useful for testing). - * @internal - */ -export function _resetRegistry(): void { - _registry.clear(); - _containerCache.clear(); -} diff --git a/vendor/bytelyst/cosmos/src/index.ts b/vendor/bytelyst/cosmos/src/index.ts deleted file mode 100644 index 49e2fce..0000000 --- a/vendor/bytelyst/cosmos/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { getCosmosClient, getDatabase, getContainer, _resetClient } from './client.js'; -export { - registerContainers, - getRegisteredContainer, - initializeAllContainers, - _resetRegistry, -} from './containers.js'; -export type { ContainerConfig } from './types.js'; diff --git a/vendor/bytelyst/cosmos/src/types.ts b/vendor/bytelyst/cosmos/src/types.ts deleted file mode 100644 index 59658f6..0000000 --- a/vendor/bytelyst/cosmos/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ContainerConfig { - partitionKeyPath: string; - defaultTtl?: number | null; -} diff --git a/vendor/bytelyst/cosmos/tsconfig.json b/vendor/bytelyst/cosmos/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/cosmos/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/create-app/package.json b/vendor/bytelyst/create-app/package.json deleted file mode 100644 index e731df4..0000000 --- a/vendor/bytelyst/create-app/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@bytelyst/create-app", - "version": "0.1.3", - "description": "CLI tools for scaffolding ByteLyst product repos and code", - "type": "module", - "bin": { - "create-app": "./dist/scaffolder.js", - "gen-api-route": "./dist/generators/api-routes.js", - "gen-agents-md": "./dist/generators/agents-md.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "create-app": "tsx src/scaffolder.ts", - "gen:api-route": "tsx src/generators/api-routes.ts", - "gen:agents-md": "tsx src/generators/agents-md.ts" - }, - "devDependencies": { - "tsx": "^4.19.2", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts b/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts deleted file mode 100644 index 7d5eac7..0000000 --- a/vendor/bytelyst/create-app/src/__tests__/scaffolder.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { generateFiles, type ProductManifest } from '../scaffolder.js'; - -function makeManifest(overrides: Partial = {}): ProductManifest { - return { - productId: 'testapp', - displayName: 'TestApp', - tagline: 'A test application', - domain: 'testapp.dev', - backendPort: 4050, - primarySurface: 'web', - platforms: ['web'], - features: ['auth', 'telemetry'], - ...overrides, - }; -} - -describe('generateFiles', () => { - it('generates root files', () => { - const files = generateFiles(makeManifest()); - const paths = files.map(f => f.path); - - expect(paths).toContain('shared/product.json'); - expect(paths).toContain('.gitignore'); - expect(paths).toContain('.env.example'); - expect(paths).toContain('README.md'); - }); - - it('always generates backend files', () => { - const files = generateFiles(makeManifest()); - const paths = files.map(f => f.path); - - expect(paths).toContain('backend/package.json'); - expect(paths).toContain('backend/src/server.ts'); - expect(paths).toContain('backend/src/lib/config.ts'); - expect(paths).toContain('backend/src/lib/auth.ts'); - expect(paths).toContain('backend/src/lib/datastore.ts'); - }); - - it('generates web files when platform includes web', () => { - const files = generateFiles(makeManifest({ platforms: ['web'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('web/package.json'); - expect(paths).toContain('web/next.config.ts'); - expect(paths).toContain('web/src/app/layout.tsx'); - expect(paths).toContain('web/src/app/page.tsx'); - expect(paths).toContain('web/src/lib/product-config.ts'); - }); - - it('does not generate web files when platform excludes web', () => { - const files = generateFiles(makeManifest({ platforms: ['mobile'] })); - const paths = files.map(f => f.path); - - expect(paths).not.toContain('web/package.json'); - expect(paths).not.toContain('web/src/app/page.tsx'); - }); - - it('generates mobile files when platform includes mobile', () => { - const files = generateFiles(makeManifest({ platforms: ['mobile'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('mobile/package.json'); - expect(paths).toContain('mobile/app.json'); - expect(paths).toContain('mobile/src/app/index.tsx'); - }); - - it('does not generate mobile files when platform excludes mobile', () => { - const files = generateFiles(makeManifest({ platforms: ['web'] })); - const paths = files.map(f => f.path); - - expect(paths).not.toContain('mobile/package.json'); - }); - - it('replaces product ID in generated content', () => { - const files = generateFiles(makeManifest({ productId: 'myproduct' })); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('"productId": "myproduct"'); - }); - - it('replaces display name in generated content', () => { - const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' })); - const readme = files.find(f => f.path === 'README.md')!; - expect(readme.content).toContain('# AwesomeApp'); - }); - - it('replaces backend port in config', () => { - const files = generateFiles(makeManifest({ backendPort: 4099 })); - const config = files.find(f => f.path === 'backend/src/lib/config.ts')!; - expect(config.content).toContain('4099'); - }); - - it('includes ios bundle ID when ios platform selected', () => { - const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' })); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('com.bytelyst.myapp'); - }); - - it('includes android bundle ID when android platform selected', () => { - const files = generateFiles( - makeManifest({ platforms: ['web', 'android'], productId: 'myapp' }) - ); - const productJson = files.find(f => f.path === 'shared/product.json')!; - expect(productJson.content).toContain('com.myapp.app'); - }); - - it('includes backend env vars in .env.example', () => { - const files = generateFiles(makeManifest({ backendPort: 4050 })); - const env = files.find(f => f.path === '.env.example')!; - expect(env.content).toContain('PORT=4050'); - expect(env.content).toContain('JWT_SECRET'); - }); - - it('generates correct web product-config', () => { - const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 })); - const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!; - expect(webConfig.content).toContain("productId: 'testprod'"); - expect(webConfig.content).toContain('4077'); - }); - - it('generates all platforms when all selected', () => { - const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] })); - const paths = files.map(f => f.path); - - expect(paths).toContain('web/package.json'); - expect(paths).toContain('mobile/package.json'); - // Backend is always included - expect(paths).toContain('backend/package.json'); - }); - - it('server.ts includes display name in log message', () => { - const files = generateFiles(makeManifest({ displayName: 'CoolProduct' })); - const server = files.find(f => f.path === 'backend/src/server.ts')!; - expect(server.content).toContain('CoolProduct backend listening'); - }); -}); diff --git a/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts b/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts deleted file mode 100644 index 2321866..0000000 --- a/vendor/bytelyst/create-app/src/__tests__/template-engine.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { renderTemplate } from '../lib/template-engine.js'; - -describe('renderTemplate', () => { - it('replaces simple variables', () => { - const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' }); - expect(result).toBe('Hello World!'); - }); - - it('replaces multiple variables', () => { - const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' }); - expect(result).toBe('foo and bar'); - }); - - it('replaces numeric variables', () => { - const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 }); - expect(result).toBe('Port: 4017'); - }); - - it('leaves unknown variables intact', () => { - const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' }); - expect(result).toBe('yes {{UNKNOWN}}'); - }); - - it('includes IF block when truthy', () => { - const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true }); - expect(result).toBe('web here'); - }); - - it('excludes IF block when falsy', () => { - const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', { - HAS_WEB: false, - }); - expect(result).toBe('beforeafter'); - }); - - it('includes UNLESS block when falsy', () => { - const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { - HAS_WEB: false, - }); - expect(result).toBe('no web'); - }); - - it('excludes UNLESS block when truthy', () => { - const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', { - HAS_WEB: true, - }); - expect(result).toBe(''); - }); - - it('handles nested variables inside IF blocks', () => { - const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', { - HAS_WEB: true, - PORT: 3000, - }); - expect(result).toBe('Port: 3000'); - }); - - it('handles multiline IF blocks', () => { - const tmpl = `start -{{#IF HAS_BACKEND}}backend line 1 -backend line 2 -{{/IF HAS_BACKEND}}end`; - const result = renderTemplate(tmpl, { HAS_BACKEND: true }); - expect(result).toContain('backend line 1'); - expect(result).toContain('backend line 2'); - expect(result).toContain('start'); - expect(result).toContain('end'); - }); - - it('handles multiple IF blocks', () => { - const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}'; - expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|'); - expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb'); - expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb'); - }); -}); diff --git a/vendor/bytelyst/create-app/src/generators/agents-md.ts b/vendor/bytelyst/create-app/src/generators/agents-md.ts deleted file mode 100644 index 9c4b9bd..0000000 --- a/vendor/bytelyst/create-app/src/generators/agents-md.ts +++ /dev/null @@ -1,605 +0,0 @@ -#!/usr/bin/env node -/** - * AGENTS.md Auto-Generator - * - * Generates AGENTS.md from product.json + repo directory scan. - * Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules - * - * Usage: - * npx tsx agents-md.ts --repo /path/to/product-repo - * npx tsx agents-md.ts --repo /path/to/product-repo --dry-run - * npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves sections - * - * @module @bytelyst/create-app/generators/agents-md - */ - -/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { execSync } from 'node:child_process'; - -// ── CLI ────────────────────────────────────────────────────────────────────── - -interface Options { - repo: string; - dryRun: boolean; - update: boolean; -} - -function parseArgs(): Options { - const args = process.argv.slice(2); - const options: Options = { repo: '.', dryRun: false, update: false }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--repo' || arg === '-r') options.repo = args[++i]; - else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; - else if (arg === '--update' || arg === '-u') options.update = true; - else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); - } - } - - return options; -} - -function showHelp(): void { - console.log(` -AGENTS.md Auto-Generator - -Generates AGENTS.md from product.json + repo scan, plus symlinks for -CLAUDE.md, .cursorrules, and .windsurfrules. - -Usage: - npx tsx agents-md.ts --repo [--dry-run] [--update] - -Options: - --repo, -r Path to product repo root (default: ".") - --dry-run, -d Preview without writing files - --update, -u Preserve sections in existing AGENTS.md - --help, -h Show this help - -Custom Sections: - Wrap any hand-written content in / - markers. The --update flag preserves these sections when regenerating. -`); -} - -// ── Product.json Loader ────────────────────────────────────────────────────── - -interface ProductManifest { - productId: string; - displayName: string; - tagline?: string; - domain?: string; - backendPort?: number; - primarySurface?: string; - mobileCompanion?: boolean; - bundleIds?: Record; - bundleId?: string; - version?: string; - description?: string; - licensePrefix?: string; - configDirName?: string; - envVarPrefix?: string; -} - -async function loadProductJson(repoPath: string): Promise { - const candidates = [ - path.join(repoPath, 'shared', 'product.json'), - path.join(repoPath, 'product.json'), - ]; - - for (const p of candidates) { - try { - const raw = await fs.readFile(p, 'utf-8'); - return JSON.parse(raw); - } catch { - // try next - } - } - - throw new Error('product.json not found in shared/ or repo root'); -} - -// ── Repo Scanner ───────────────────────────────────────────────────────────── - -interface RepoInfo { - repoName: string; - hasFastifyBackend: boolean; - hasNextWeb: boolean; - hasExpoMobile: boolean; - hasSwiftIos: boolean; - hasKotlinAndroid: boolean; - hasKmpShared: boolean; - backendModules: string[]; - backendTestCount: number; - webTestCount: number; - mobileTestCount: number; - backendLibFiles: string[]; - webLibFiles: string[]; - cosmosContainers: string[]; - techStack: { layer: string; tech: string }[]; - buildCommands: string[]; -} - -async function dirExists(p: string): Promise { - try { - const st = await fs.stat(p); - return st.isDirectory(); - } catch { - return false; - } -} - -async function fileExists(p: string): Promise { - try { - await fs.access(p); - return true; - } catch { - return false; - } -} - -function countTestsInFiles(dir: string): number { - try { - const output = execSync( - `grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`, - { encoding: 'utf-8', timeout: 5000 } - ); - return parseInt(output.trim()) || 0; - } catch { - return 0; - } -} - -async function listDirs(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter(e => e.isDirectory()).map(e => e.name); - } catch { - return []; - } -} - -async function listFiles(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter(e => e.isFile()).map(e => e.name); - } catch { - return []; - } -} - -async function scanRepo(repoPath: string, manifest: ProductManifest): Promise { - const repoName = path.basename(repoPath); - - // Detect surfaces - const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src')); - const hasNextWeb = - (await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) || - (await fileExists(path.join(repoPath, 'web', 'next.config.js'))) || - (await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts'))); - const hasExpoMobile = - (await fileExists(path.join(repoPath, 'mobile', 'app.json'))) || - (await fileExists(path.join(repoPath, 'app.json'))); - const hasSwiftIos = await dirExists(path.join(repoPath, 'ios')); - const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android')); - const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src')); - - // Backend modules - const modulesDir = path.join(repoPath, 'backend', 'src', 'modules'); - const backendModules = await listDirs(modulesDir); - - // Backend lib files - const libDir = path.join(repoPath, 'backend', 'src', 'lib'); - const backendLibFiles = (await listFiles(libDir)).filter( - f => f.endsWith('.ts') && !f.endsWith('.test.ts') - ); - - // Web lib files - let webLibDir = path.join(repoPath, 'web', 'src', 'lib'); - if (!(await dirExists(webLibDir))) { - webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib'); - } - const webLibFiles = (await listFiles(webLibDir)).filter( - f => f.endsWith('.ts') && !f.endsWith('.test.ts') - ); - - // Test counts - const backendTestCount = hasFastifyBackend - ? countTestsInFiles(path.join(repoPath, 'backend', 'src')) - : 0; - - let webTestDir = path.join(repoPath, 'web', 'src'); - if (!(await dirExists(webTestDir))) { - webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src'); - } - const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0; - - const mobileTestDir = hasExpoMobile - ? (await dirExists(path.join(repoPath, 'mobile'))) - ? path.join(repoPath, 'mobile') - : repoPath - : ''; - const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0; - - // Cosmos containers — scan backend types files - const cosmosContainers: string[] = []; - for (const mod of backendModules) { - const typesFile = path.join(modulesDir, mod, 'types.ts'); - if (await fileExists(typesFile)) { - cosmosContainers.push(mod.replace(/-/g, '_')); - } - } - - // Tech stack - const techStack: { layer: string; tech: string }[] = []; - if (hasFastifyBackend) - techStack.push({ - layer: 'Backend', - tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`, - }); - if (hasNextWeb) - techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' }); - if (hasExpoMobile) - techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' }); - if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' }); - if (hasKotlinAndroid) - techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' }); - if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' }); - techStack.push({ - layer: 'Platform', - tech: 'platform-service (port 4003) for auth, flags, telemetry, billing', - }); - techStack.push({ - layer: 'Database', - tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`, - }); - - // Build commands - const buildCommands: string[] = []; - if (hasFastifyBackend) { - const port = manifest.backendPort ?? 4000; - buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`); - buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`); - buildCommands.push(`cd backend && npm test # Vitest tests`); - } - if (hasNextWeb) { - buildCommands.push(`cd web && npm run dev # Dev server`); - buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`); - buildCommands.push(`cd web && npm run build # Production build`); - } - if (hasExpoMobile) { - buildCommands.push(`cd mobile && npm start # Expo dev server`); - buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`); - } - - return { - repoName, - hasFastifyBackend, - hasNextWeb, - hasExpoMobile, - hasSwiftIos, - hasKotlinAndroid, - hasKmpShared, - backendModules, - backendTestCount, - webTestCount, - mobileTestCount, - backendLibFiles, - webLibFiles, - cosmosContainers, - techStack, - buildCommands, - }; -} - -// ── Markdown Generator ─────────────────────────────────────────────────────── - -function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string { - const { productId, displayName, domain, tagline } = manifest; - const repoDesc = tagline ?? `${displayName} product`; - - const lines: string[] = []; - - // ── Header - lines.push(`# AGENTS.md — AI Coding Agent Instructions`); - lines.push(''); - lines.push( - `> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.` - ); - lines.push(`> **Repo:** \`${info.repoName}\` — ${repoDesc}.`); - lines.push(''); - lines.push('---'); - lines.push(''); - - // ── 1. Project Identity - lines.push('## 1. Project Identity'); - lines.push(''); - lines.push('| Key | Value |'); - lines.push('|-----|-------|'); - lines.push(`| **Product** | ${displayName} |`); - lines.push(`| **Product ID** | \`${productId}\` |`); - if (domain) lines.push(`| **Domain** | ${domain} |`); - lines.push(`| **Repo** | \`${info.repoName}\` |`); - lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`); - lines.push(''); - - // ── 2. Repo Layout (simplified tree) - lines.push('## 2. Repo Layout'); - lines.push(''); - lines.push('```'); - lines.push(`${info.repoName}/`); - if (info.hasFastifyBackend) { - const port = manifest.backendPort ?? 4000; - lines.push( - `├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})` - ); - lines.push(`│ ├── src/`); - if (info.backendLibFiles.length > 0) { - lines.push(`│ │ ├── lib/ # Shared backend wiring`); - for (const f of info.backendLibFiles.slice(0, 8)) { - lines.push(`│ │ │ ├── ${f}`); - } - if (info.backendLibFiles.length > 8) { - lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`); - } - } - if (info.backendModules.length > 0) { - lines.push(`│ │ ├── modules/`); - for (const m of info.backendModules) { - lines.push(`│ │ │ ├── ${m}/`); - } - } - lines.push(`│ │ └── server.ts`); - lines.push(`│ ├── package.json`); - lines.push(`│ └── tsconfig.json`); - lines.push('│'); - } - if (info.hasNextWeb) { - lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`); - lines.push(`│ ├── src/`); - lines.push(`│ │ ├── app/ # App Router pages`); - if (info.webLibFiles.length > 0) { - lines.push(`│ │ └── lib/ # Pure TS clients + config`); - } - lines.push(`│ ├── package.json`); - lines.push(`│ └── tsconfig.json`); - lines.push('│'); - } - if (info.hasExpoMobile) { - lines.push(`├── mobile/ # React Native + Expo`); - lines.push('│'); - } - if (info.hasSwiftIos) { - lines.push(`├── ios/ # SwiftUI native app`); - lines.push('│'); - } - if (info.hasKotlinAndroid) { - lines.push(`├── android/ # Jetpack Compose`); - lines.push('│'); - } - lines.push(`├── shared/`); - lines.push(`│ └── product.json # Canonical product identity`); - lines.push(`├── AGENTS.md # This file`); - lines.push(`└── README.md`); - lines.push('```'); - lines.push(''); - - // ── 3. Tech Stack - lines.push('## 3. Tech Stack'); - lines.push(''); - lines.push('| Layer | Technology |'); - lines.push('|-------|-----------|'); - for (const { layer, tech } of info.techStack) { - lines.push(`| **${layer}** | ${tech} |`); - } - - // Test counts - const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount; - if (totalTests > 0) { - const parts: string[] = []; - if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`); - if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`); - if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`); - lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`); - } - lines.push(''); - - // ── 4. Coding Conventions - lines.push('## 4. Coding Conventions'); - lines.push(''); - lines.push('### MUST follow'); - lines.push(''); - lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`); - if (info.hasFastifyBackend) { - lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern'); - lines.push( - '- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls' - ); - } - if (info.hasNextWeb) { - lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports'); - lines.push('- Web components in `web/src/components/` — React UI only'); - } - if (info.hasExpoMobile) { - lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports'); - } - lines.push( - '- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`' - ); - lines.push(''); - lines.push('### MUST NOT do'); - lines.push(''); - lines.push( - '- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify' - ); - lines.push('- Never use `any` type — use Zod inference or explicit types'); - lines.push('- Never hardcode colors — use theme tokens'); - lines.push('- Never hardcode API URLs — use env vars or config'); - lines.push(`- Never hardcode product ID — use \`productConfig.productId\``); - lines.push('- Never modify tests to make them pass — fix the actual code'); - lines.push('- Never delete existing comments or documentation unless explicitly asked'); - lines.push('- Never add emojis to code unless explicitly asked'); - lines.push(''); - - // ── 5. Build & Test Commands - if (info.buildCommands.length > 0) { - lines.push('## 5. Build & Test Commands'); - lines.push(''); - lines.push('```bash'); - for (const cmd of info.buildCommands) { - lines.push(cmd); - } - lines.push('```'); - lines.push(''); - } - - // ── 6. Backend API Modules (if applicable) - if (info.hasFastifyBackend && info.backendModules.length > 0) { - lines.push('## 6. Backend Modules'); - lines.push(''); - lines.push('| Module | Container | Description |'); - lines.push('|--------|-----------|-------------|'); - for (const mod of info.backendModules) { - const container = mod.replace(/-/g, '_'); - lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`); - } - lines.push(''); - } - - // ── Custom section placeholder - lines.push(''); - lines.push(''); - lines.push(''); - - return lines.join('\n'); -} - -// ── Custom Section Preservation ────────────────────────────────────────────── - -function extractCustomSections(content: string): Map { - const sections = new Map(); - const regex = /\n([\s\S]*?)/g; - let match; - while ((match = regex.exec(content)) !== null) { - sections.set(match[1], match[2]); - } - return sections; -} - -function mergeCustomSections(newContent: string, existing: Map): string { - let result = newContent; - for (const [key, value] of existing) { - const placeholder = `\n`; - const replacement = `\n${value}`; - result = result.replace(placeholder, replacement); - } - return result; -} - -// ── Symlink Manager ────────────────────────────────────────────────────────── - -async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise { - const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules']; - - for (const target of targets) { - const linkPath = path.join(repoPath, target); - const exists = await fileExists(linkPath); - - if (exists) { - try { - const stat = await fs.lstat(linkPath); - if (stat.isSymbolicLink()) { - const linkTarget = await fs.readlink(linkPath); - if (linkTarget === 'AGENTS.md') { - continue; // already correct - } - } - } catch { - // not a symlink - } - } - - if (dryRun) { - console.log(` 📝 Would create symlink: ${target} → AGENTS.md`); - } else { - try { - if (exists) await fs.unlink(linkPath); - await fs.symlink('AGENTS.md', linkPath); - console.log(` ✅ ${target} → AGENTS.md`); - } catch (err) { - console.log( - ` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } - } -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { repo, dryRun, update } = parseArgs(); - const repoPath = path.resolve(repo); - - console.log(`\n📄 AGENTS.md Generator`); - console.log(` Repo: ${repoPath}`); - if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); - if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n'); - - // Load product.json - const manifest = await loadProductJson(repoPath); - console.log(` Product: ${manifest.displayName} (${manifest.productId})`); - - // Scan repo - const info = await scanRepo(repoPath, manifest); - console.log(` Backend modules: ${info.backendModules.length}`); - console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`); - console.log(''); - - // Generate content - let content = generateAgentsMd(manifest, info); - - // Merge custom sections if updating - if (update) { - const agentsPath = path.join(repoPath, 'AGENTS.md'); - try { - const existing = await fs.readFile(agentsPath, 'utf-8'); - const customSections = extractCustomSections(existing); - if (customSections.size > 0) { - console.log(` 🔄 Preserving ${customSections.size} custom section(s)`); - content = mergeCustomSections(content, customSections); - } - } catch { - console.log(' ℹ️ No existing AGENTS.md to preserve custom sections from'); - } - } - - if (dryRun) { - console.log('── AGENTS.md ──────────────────────────────────────'); - console.log(content); - console.log('\n── Symlinks ──────────────────────────────────────'); - await ensureSymlinks(repoPath, true); - console.log('\n✨ Dry run complete.'); - return; - } - - // Write AGENTS.md - const agentsPath = path.join(repoPath, 'AGENTS.md'); - await fs.writeFile(agentsPath, content, 'utf-8'); - console.log(` ✅ AGENTS.md written`); - - // Ensure symlinks - await ensureSymlinks(repoPath, false); - - console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`); -} - -main().catch(err => { - console.error('❌ Error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/vendor/bytelyst/create-app/src/generators/api-routes.ts b/vendor/bytelyst/create-app/src/generators/api-routes.ts deleted file mode 100644 index 6c38101..0000000 --- a/vendor/bytelyst/create-app/src/generators/api-routes.ts +++ /dev/null @@ -1,770 +0,0 @@ -#!/usr/bin/env node -/** - * API Route Generator — Next.js App Router - * - * Generates two files: - * src/app/api//route.ts — GET (list) + POST (create) - * src/app/api//[id]/route.ts — GET (detail) + PATCH (update) + DELETE - * - * Follows the pattern used across ByteLyst product dashboards: - * - Named exports (export const GET, POST, PATCH, DELETE) - * - withErrorHandler HOF wrapper - * - Auth via getCurrentUser / getAccessToken - * - Zod validation on POST/PATCH bodies - * - NextRequest + NextResponse - * - * Usage: - * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src - * npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src - * - * Modes: - * --mode direct (default) Direct Cosmos DB access via repository functions - * --mode proxy Proxy to product backend via fetch (for thin web clients) - * - * @module @bytelyst/create-app/generators/api-routes - */ - -/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; - -// ── CLI ────────────────────────────────────────────────────────────────────── - -interface Options { - name: string; - fields: string; - target: string; - mode: 'direct' | 'proxy'; - methods: string[]; - withHandler: boolean; - dryRun: boolean; -} - -function parseArgs(): Options { - const args = process.argv.slice(2); - const options: Options = { - name: '', - fields: '', - target: './src', - mode: 'direct', - methods: ['GET', 'POST', 'PATCH', 'DELETE'], - withHandler: true, - dryRun: false, - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--name' || arg === '-n') options.name = args[++i]; - else if (arg === '--fields' || arg === '-f') options.fields = args[++i]; - else if (arg === '--target' || arg === '-t') options.target = args[++i]; - else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy'; - else if (arg === '--methods') - options.methods = args[++i].split(',').map(m => m.trim().toUpperCase()); - else if (arg === '--no-handler') options.withHandler = false; - else if (arg === '--dry-run' || arg === '-d') options.dryRun = true; - else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); - } - } - - if (!options.name) { - console.error('Error: --name is required'); - showHelp(); - process.exit(1); - } - - if (!/^[a-z][a-z0-9-]*$/.test(options.name)) { - console.error('Error: --name must be lowercase alphanumeric with optional hyphens'); - process.exit(1); - } - - if (!['direct', 'proxy'].includes(options.mode)) { - console.error('Error: --mode must be "direct" or "proxy"'); - process.exit(1); - } - - return options; -} - -function showHelp(): void { - console.log(` -API Route Generator — Next.js App Router - -Generates CRUD API route files for a Next.js App Router project. - -Usage: - npx tsx api-routes.ts --name [--fields ""] [options] - -Options: - --name, -n Entity name (e.g., "tasks", "sessions") [required] - --fields, -f Comma-separated field definitions [optional for proxy mode] - --target, -t Target src/ directory (default: "./src") - --mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct] - --methods HTTP methods to generate (default: GET,POST,PATCH,DELETE) - --no-handler Skip withErrorHandler wrapper - --dry-run, -d Preview without writing files - --help, -h Show this help - -Field Types (same as gen-module): - string z.string() - number z.number() - boolean z.boolean() - date z.string().datetime() - enum(a,b,c) z.enum(["a","b","c"]) - Append ? for optional fields - -Modes: - direct — Generates route files that call repository functions (direct Cosmos DB). - Also generates a lib/repositories/.ts and lib/schemas/.ts. - proxy — Generates route files that proxy to a product backend via fetch. - Requires lib/api-helpers.ts to exist (createApiClient pattern). - -Examples: - # Direct mode (full CRUD with Cosmos) - npx tsx api-routes.ts --name tasks \\ - --fields "title:string,status:enum(pending,active,done),priority:number?" \\ - --target ./src - - # Proxy mode (thin web client forwarding to backend) - npx tsx api-routes.ts --name tasks --mode proxy --target ./src - - # Preview only - npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run -`); -} - -// ── Field Parser ───────────────────────────────────────────────────────────── - -interface ParsedField { - name: string; - type: string; - optional: boolean; - zodType: string; - tsType: string; - enumValues: string[] | null; -} - -function splitFields(str: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ''; - for (const ch of str) { - if (ch === '(') depth++; - if (ch === ')') depth--; - if (ch === ',' && depth === 0) { - parts.push(current.trim()); - current = ''; - } else { - current += ch; - } - } - if (current.trim()) parts.push(current.trim()); - return parts; -} - -function parseFields(fieldsStr: string): ParsedField[] { - if (!fieldsStr) return []; - const fields: ParsedField[] = []; - const fieldDefs = splitFields(fieldsStr); - - for (const raw of fieldDefs) { - const def = raw.trim(); - if (!def) continue; - - const optional = def.endsWith('?'); - const cleaned = optional ? def.slice(0, -1) : def; - const colonIdx = cleaned.indexOf(':'); - if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`); - - const name = cleaned.slice(0, colonIdx).trim(); - const type = cleaned.slice(colonIdx + 1).trim(); - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - throw new Error(`Invalid field name: ${name}`); - } - - let zodType: string; - let tsType: string; - let enumValues: string[] | null = null; - - if (type.startsWith('enum(') && type.endsWith(')')) { - enumValues = type - .slice(5, -1) - .split(',') - .map(v => v.trim()) - .filter(Boolean); - if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`); - zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`; - tsType = enumValues.map(v => `'${v}'`).join(' | '); - } else { - const MAP: Record = { - string: { zod: 'z.string().min(1)', ts: 'string' }, - number: { zod: 'z.number()', ts: 'number' }, - boolean: { zod: 'z.boolean()', ts: 'boolean' }, - date: { zod: 'z.string().datetime()', ts: 'string' }, - datetime: { zod: 'z.string().datetime()', ts: 'string' }, - 'string[]': { zod: 'z.array(z.string())', ts: 'string[]' }, - 'number[]': { zod: 'z.array(z.number())', ts: 'number[]' }, - }; - const mapping = MAP[type]; - if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`); - zodType = mapping.zod; - tsType = mapping.ts; - } - - fields.push({ name, type, optional, zodType, tsType, enumValues }); - } - return fields; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function pascal(s: string): string { - return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase()); -} -// ── Proxy Mode Templates ──────────────────────────────────────────────────── - -function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string { - const hasGet = methods.includes('GET'); - const hasPost = methods.includes('POST'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getAccessToken } from '@/lib/api-helpers'; -${handlerImport} -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const url = new URL(req.url); - const params = url.searchParams.toString(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, { - headers: { Authorization: \`Bearer \${token}\` }, - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasPost) { - out += ` -export const POST = ${wrap(`async (req: NextRequest) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await req.json(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, { - method: 'POST', - headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - return out; -} - -function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string { - const hasGet = methods.includes('GET'); - const hasPatch = methods.includes('PATCH'); - const hasDelete = methods.includes('DELETE'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getAccessToken } from '@/lib/api-helpers'; -${handlerImport} -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000'; - -type RouteContext = { params: Promise<{ id: string }> }; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - headers: { Authorization: \`Bearer \${token}\` }, - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasPatch) { - out += ` -export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const body = await req.json(); - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - method: 'PATCH', - headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - if (hasDelete) { - out += ` -export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => { - const token = await getAccessToken(req); - if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, { - method: 'DELETE', - headers: { Authorization: \`Bearer \${token}\` }, - }); - if (res.status === 204) return new NextResponse(null, { status: 204 }); - const data = await res.json(); - return NextResponse.json(data, { status: res.status }); -}`)}; -`; - } - - return out; -} - -// ── Direct Mode Templates ─────────────────────────────────────────────────── - -function genSchemaFile(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const createFields = fields - .map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`) - .join('\n'); - const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n'); - - return `import { z } from 'zod'; - -export const Create${P}Schema = z.object({ -${createFields} -}); - -export const Update${P}Schema = z.object({ -${updateFields} -}); - -export type Create${P}Input = z.infer; -export type Update${P}Input = z.infer; -`; -} - -function genRepositoryFile(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n'); - - return `import { randomUUID } from 'node:crypto'; -import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore'; -import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}'; - -export interface ${P}Doc { - id: string; - productId: string; - userId: string; -${docFields} - createdAt: string; - updatedAt: string; -} - -function getContainer() { - return getCosmosContainer('${name}'); -} - -export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> { - const { resources } = await getContainer() - .items.query<${P}Doc>({ - query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim', - parameters: [ - { name: '@uid', value: userId }, - { name: '@pid', value: PRODUCT_ID }, - { name: '@off', value: offset }, - { name: '@lim', value: limit }, - ], - }) - .fetchAll(); - return resources; -} - -export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> { - try { - const { resource } = await getContainer().item(id, userId).read<${P}Doc>(); - return resource ?? null; - } catch { - return null; - } -} - -export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> { - const now = new Date().toISOString(); - const doc: ${P}Doc = { - id: \`${name.slice(0, 3)}_\${randomUUID()}\`, - productId: PRODUCT_ID, - userId, - ...input, - createdAt: now, - updatedAt: now, - }; - await getContainer().items.create(doc); - return doc; -} - -export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> { - const existing = await get${P}(id, userId); - if (!existing) return null; - - const updated: ${P}Doc = { - ...existing, - ...updates, - updatedAt: new Date().toISOString(), - }; - await getContainer().item(id, userId).replace(updated); - return updated; -} - -export async function delete${P}(id: string, userId: string): Promise { - try { - await getContainer().item(id, userId).delete(); - return true; - } catch { - return false; - } -} -`; -} - -function genDirectListRoute( - name: string, - fields: ParsedField[], - methods: string[], - withHandler: boolean -): string { - const P = pascal(name); - const hasGet = methods.includes('GET'); - const hasPost = methods.includes('POST'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - const imports: string[] = []; - if (hasGet) imports.push(`list${P}`); - if (hasPost) imports.push(`create${P}`); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth-server'; -${handlerImport}`; - - if (imports.length > 0) { - out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; - } - - if (hasPost) { - out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`; - } - - if (hasGet) { - out += ` -export const GET = ${wrap(`async (req: NextRequest) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const url = new URL(req.url); - const limit = parseInt(url.searchParams.get('limit') ?? '50'); - const offset = parseInt(url.searchParams.get('offset') ?? '0'); - - const items = await list${P}(user.id, limit, offset); - return NextResponse.json({ items }); -}`)}; -`; - } - - if (hasPost) { - out += ` -export const POST = ${wrap(`async (req: NextRequest) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const body = await req.json(); - const input = Create${P}Schema.parse(body); - const item = await create${P}(user.id, input); - return NextResponse.json(item, { status: 201 }); -}`)}; -`; - } - - return out; -} - -function genDirectDetailRoute( - name: string, - _fields: ParsedField[], - methods: string[], - withHandler: boolean -): string { - const P = pascal(name); - const hasGet = methods.includes('GET'); - const hasPatch = methods.includes('PATCH'); - const hasDelete = methods.includes('DELETE'); - const handlerImport = withHandler - ? `import { withErrorHandler } from '@/lib/api-handler';\n` - : ''; - const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code); - - const imports: string[] = []; - if (hasGet) imports.push(`get${P}`); - if (hasPatch) imports.push(`update${P}`); - if (hasDelete) imports.push(`delete${P}`); - - let out = `import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth-server'; -${handlerImport}`; - - if (imports.length > 0) { - out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`; - } - - if (hasPatch) { - out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`; - } - - out += ` -type RouteContext = { params: Promise<{ id: string }> }; -`; - - if (hasGet) { - out += ` -export const GET = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const item = await get${P}(id, user.id); - if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json(item); -}`)}; -`; - } - - if (hasPatch) { - out += ` -export const PATCH = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const body = await req.json(); - const updates = Update${P}Schema.parse(body); - const item = await update${P}(id, user.id, updates); - if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json(item); -}`)}; -`; - } - - if (hasDelete) { - out += ` -export const DELETE = ${wrap(`async ( - req: NextRequest, - { params }: RouteContext, -) => { - const user = await getCurrentUser(req.headers.get('authorization')); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const { id } = await params; - const deleted = await delete${P}(id, user.id); - if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - - return NextResponse.json({ success: true }); -}`)}; -`; - } - - return out; -} - -// ── Test Template ──────────────────────────────────────────────────────────── - -function genSchemaTest(name: string, fields: ParsedField[]): string { - const P = pascal(name); - const requiredFields = fields.filter(f => !f.optional); - - function sampleValue(f: ParsedField): string { - if (f.enumValues) return `'${f.enumValues[0]}'`; - if (f.tsType === 'string') return `'test'`; - if (f.tsType === 'number') return '42'; - if (f.tsType === 'boolean') return 'true'; - return `'2026-01-01T00:00:00.000Z'`; - } - - const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n'); - - return `import { describe, it, expect } from 'vitest'; -import { Create${P}Schema, Update${P}Schema } from './schemas/${name}'; - -describe('Create${P}Schema', () => { - it('accepts valid input', () => { - const result = Create${P}Schema.parse({ -${validPayload} - }); - expect(result).toBeDefined(); - }); - - it('rejects empty object', () => { - expect(() => Create${P}Schema.parse({})).toThrow(); - }); -}); - -describe('Update${P}Schema', () => { - it('accepts empty object (all optional)', () => { - const result = Update${P}Schema.parse({}); - expect(result).toEqual({}); - }); - - it('accepts partial update', () => { - const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} }); - expect(result).toBeDefined(); - }); -}); -`; -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs(); - - console.log(`\n🚀 Generating API routes: ${name}`); - console.log(` Mode: ${mode}`); - console.log(` Methods: ${methods.join(', ')}`); - console.log(` Target: ${target}`); - if (fields) console.log(` Fields: ${fields}`); - if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n'); - - const parsedFields = parseFields(fields); - - // Determine which files to generate - const files: { path: string; content: string }[] = []; - - if (mode === 'proxy') { - files.push({ - path: `app/api/${name}/route.ts`, - content: genProxyListRoute(name, methods, withHandler), - }); - if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { - files.push({ - path: `app/api/${name}/[id]/route.ts`, - content: genProxyDetailRoute(name, methods, withHandler), - }); - } - } else { - // Direct mode — also generate schema + repository - if (parsedFields.length === 0) { - console.error('Error: --fields is required in direct mode'); - process.exit(1); - } - - files.push({ - path: `lib/schemas/${name}.ts`, - content: genSchemaFile(name, parsedFields), - }); - files.push({ - path: `lib/repositories/${name}.ts`, - content: genRepositoryFile(name, parsedFields), - }); - files.push({ - path: `app/api/${name}/route.ts`, - content: genDirectListRoute(name, parsedFields, methods, withHandler), - }); - if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) { - files.push({ - path: `app/api/${name}/[id]/route.ts`, - content: genDirectDetailRoute(name, parsedFields, methods, withHandler), - }); - } - files.push({ - path: `lib/__tests__/${name}.test.ts`, - content: genSchemaTest(name, parsedFields), - }); - } - - if (dryRun) { - console.log('📄 Generated files:\n'); - for (const file of files) { - console.log(`── ${file.path} ──────────────────────────────────────`); - console.log(file.content); - } - console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`); - return; - } - - // Write files - for (const file of files) { - const fullPath = path.join(target, file.path); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - - // Check if file already exists - try { - await fs.access(fullPath); - console.log(` ⚠️ SKIP ${file.path} (already exists)`); - continue; - } catch { - // Good — doesn't exist - } - - await fs.writeFile(fullPath, file.content, 'utf-8'); - console.log(` ✅ ${file.path}`); - } - - console.log(`\n✨ API routes generated for "${name}".`); - if (mode === 'direct') { - console.log(`\nPrerequisites (if not already present):`); - console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`); - console.log(` - lib/api-handler.ts — withErrorHandler HOF`); - console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`); - console.log(` - zod installed — npm install zod`); - } else { - console.log(`\nPrerequisites (if not already present):`); - console.log(` - lib/api-helpers.ts — getAccessToken(req) function`); - console.log(` - lib/api-handler.ts — withErrorHandler HOF`); - console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`); - } -} - -main().catch(err => { - console.error('❌ Error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); diff --git a/vendor/bytelyst/create-app/src/index.ts b/vendor/bytelyst/create-app/src/index.ts deleted file mode 100644 index 48fc97d..0000000 --- a/vendor/bytelyst/create-app/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @bytelyst/create-app — CLI tools for scaffolding ByteLyst product repos. - * - * Generators: - * - api-routes.ts — Next.js App Router API route generator - * - agents-md.ts — AGENTS.md auto-generator from product.json + repo scan - */ - -export {}; diff --git a/vendor/bytelyst/create-app/src/lib/template-engine.ts b/vendor/bytelyst/create-app/src/lib/template-engine.ts deleted file mode 100644 index 629f5e3..0000000 --- a/vendor/bytelyst/create-app/src/lib/template-engine.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Simple template engine for scaffolding. - * Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks. - */ - -export type TemplateVars = Record; - -/** - * Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks. - */ -export function renderTemplate(template: string, vars: TemplateVars): string { - let result = template; - - // Process conditional blocks: {{#IF KEY}}...{{/IF KEY}} - const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g; - result = result.replace(ifRegex, (_, key: string, content: string) => { - return vars[key] ? content : ''; - }); - - // Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}} - const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g; - result = result.replace(unlessRegex, (_, key: string, content: string) => { - return !vars[key] ? content : ''; - }); - - // Replace {{VARIABLE}} placeholders - result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { - const val = vars[key]; - if (val === undefined) return match; - return String(val); - }); - - return result; -} diff --git a/vendor/bytelyst/create-app/src/lib/templates.ts b/vendor/bytelyst/create-app/src/lib/templates.ts deleted file mode 100644 index fd44728..0000000 --- a/vendor/bytelyst/create-app/src/lib/templates.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Inline templates for product repo scaffolding. - * Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax. - */ - -// ── product.json ───────────────────────────────────────────────────────────── - -export const PRODUCT_JSON = `{ - "productId": "{{PRODUCT_ID}}", - "displayName": "{{DISPLAY_NAME}}", - "tagline": "{{TAGLINE}}", - "domain": "{{DOMAIN}}", - "backendPort": {{BACKEND_PORT}}, - "primarySurface": "{{PRIMARY_SURFACE}}", - "bundleIds": { - "web": "{{DOMAIN}}"{{#IF HAS_IOS}}, - "ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}}, - "android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}} - }, - "appStore": { - "category": "Productivity", - "privacyUrl": "https://{{DOMAIN}}/privacy", - "termsUrl": "https://{{DOMAIN}}/terms", - "supportUrl": "https://{{DOMAIN}}/support" - }, - "version": "0.1.0" -} -`; - -// ── .gitignore ─────────────────────────────────────────────────────────────── - -export const GITIGNORE = `node_modules/ -dist/ -.next/ -.env -.env.local -*.log -.DS_Store -coverage/ -`; - -// ── .env.example ───────────────────────────────────────────────────────────── - -export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables -NODE_ENV=development -{{#IF HAS_BACKEND}} -# Backend -PORT={{BACKEND_PORT}} -HOST=0.0.0.0 -JWT_SECRET=dev-secret-change-me -DB_PROVIDER=memory -COSMOS_ENDPOINT= -COSMOS_KEY= -COSMOS_DATABASE=bytelyst -PLATFORM_SERVICE_URL=http://localhost:4003 -{{/IF HAS_BACKEND}} -{{#IF HAS_WEB}} -# Web -NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}} -{{/IF HAS_WEB}} -`; - -// ── README.md ──────────────────────────────────────────────────────────────── - -export const README = `# {{DISPLAY_NAME}} - -> {{TAGLINE}} - -## Quick Start - -\`\`\`bash -{{#IF HAS_BACKEND}}# Backend -cd backend && npm install && npm run dev -{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web -cd web && npm install && npm run dev -{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile -cd mobile && npm install && npm start -{{/IF HAS_MOBILE}}\`\`\` - -## Architecture - -| Layer | Technology | -|-------|-----------| -{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) | -{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 | -{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) | -{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) | -| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) | - -## Product Identity - -- **Product ID:** \`{{PRODUCT_ID}}\` -- **Domain:** {{DOMAIN}} -- **Backend Port:** {{BACKEND_PORT}} - -See [AGENTS.md](AGENTS.md) for AI agent instructions. -`; - -// ── Backend templates ──────────────────────────────────────────────────────── - -export const BACKEND_PACKAGE_JSON = `{ - "name": "@{{PRODUCT_ID}}/backend", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "tsx watch src/server.ts", - "build": "tsc", - "start": "node dist/server.js", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", - "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", - "@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore", - "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", - "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", - "fastify": "^5.3.3", - "jose": "^6.0.11", - "zod": "^3.24.4" - }, - "devDependencies": { - "tsx": "^4.19.2", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - } -} -`; - -export const BACKEND_TSCONFIG = `{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.ts"] -} -`; - -export const BACKEND_CONFIG = `import { z } from 'zod'; -import { PRODUCT_ID } from './product-config.js'; - -const envSchema = z.object({ - PORT: z.coerce.number().default({{BACKEND_PORT}}), - HOST: z.string().default('0.0.0.0'), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - CORS_ORIGIN: z.string().optional(), - SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'), - COSMOS_ENDPOINT: z.string().optional(), - COSMOS_KEY: z.string().optional(), - COSMOS_DATABASE: z.string().default('bytelyst'), - JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), - DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'), - PRODUCT_ID: z.string().default(PRODUCT_ID), - PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), -}); - -export const config = envSchema.parse(process.env); -`; - -export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8'); -const manifest = JSON.parse(raw); -export const PRODUCT_ID: string = manifest.productId; -`; - -export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose'; -import type { FastifyRequest } from 'fastify'; -import { config } from './config.js'; - -const secret = new TextEncoder().encode(config.JWT_SECRET); - -export interface JwtPayload { - sub: string; - email?: string; - role?: string; -} - -export async function verifyToken(token: string): Promise { - try { - const { payload } = await jwtVerify(token, secret); - return payload as unknown as JwtPayload; - } catch { - return null; - } -} - -export function extractToken(req: FastifyRequest): string | null { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return null; - return auth.slice(7); -} -`; - -export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify'; -import { verifyToken, extractToken, type JwtPayload } from './auth.js'; -import { config } from './config.js'; - -export async function getUserPayload(req: FastifyRequest): Promise { - const token = extractToken(req); - if (!token) return null; - return verifyToken(token); -} - -export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string { - if (!req.user?.sub) throw new Error('Unauthenticated'); - return req.user.sub; -} - -export function getRequestProductId(_req: FastifyRequest): string { - return config.PRODUCT_ID; -} -`; - -export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors'; -`; - -export const BACKEND_DATASTORE = `import { config } from './config.js'; - -const collections = new Map>(); - -export function getCollection(name: string): Map { - if (!collections.has(name)) { - collections.set(name, new Map()); - } - return collections.get(name) as Map; -} - -export const PRODUCT_ID = config.PRODUCT_ID; -export const DB_PROVIDER = config.DB_PROVIDER; -`; - -export const BACKEND_SERVER = `import Fastify from 'fastify'; -import { config } from './lib/config.js'; - -const app = Fastify({ - logger: { - level: config.NODE_ENV === 'test' ? 'silent' : 'info', - }, -}); - -// CORS -if (config.CORS_ORIGIN) { - app.addHook('onRequest', async (request, reply) => { - reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN); - reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS'); - reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - if (request.method === 'OPTIONS') { - reply.status(204).send(); - } - }); -} - -// Health check -app.get('/health', async () => ({ - status: 'ok', - service: config.SERVICE_NAME, - productId: config.PRODUCT_ID, -})); - -// TODO: Register your route modules here -// import { routes as exampleRoutes } from './modules/example/routes.js'; -// app.register(exampleRoutes, { prefix: '/api' }); - -async function start() { - try { - await app.listen({ port: config.PORT, host: config.HOST }); - app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`); - } catch (err) { - app.log.error(err); - process.exit(1); - } -} - -start(); - -export { app }; -`; - -// ── Web templates ──────────────────────────────────────────────────────────── - -export const WEB_PACKAGE_JSON = `{ - "name": "@{{PRODUCT_ID}}/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build --webpack", - "start": "next start", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "next": "^16.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@types/node": "^22.16.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "typescript": "^5.7.3" - } -} -`; - -export const WEB_TSCONFIG = `{ - "compilerOptions": { - "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} -`; - -export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - reactStrictMode: true, -}; - -export default nextConfig; -`; - -export const WEB_LAYOUT = `import type { Metadata } from 'next'; - -export const metadata: Metadata = { - title: '{{DISPLAY_NAME}}', - description: '{{TAGLINE}}', -}; - -export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} -`; - -export const WEB_PAGE = `export default function Home() { - return ( -
-

{{DISPLAY_NAME}}

-

{{TAGLINE}}

-

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

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

- {title} -

-

- {description} -

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

- {title} -

-

- {message} -

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

- {title} -

-

- {message} -

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

- {title} -

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

- Billing -

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

- Available Plans -

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

- Profile -

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

- Settings -

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

- {section.title} -

- {section.description && ( -

- {section.description} -

- )} -
{section.content}
-
- ))} -
- ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx b/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx deleted file mode 100644 index 4bc7743..0000000 --- a/vendor/bytelyst/dashboard-shell/src/Sidebar.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import type { ReactNode } from 'react'; -import type { SidebarProps, NavItem, NavSection } from './types.js'; - -function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] { - return nav.length > 0 && 'items' in nav[0]; -} - -function NavLink({ - item, - active, - collapsed, - onNavigate, -}: { - item: NavItem; - active: boolean; - collapsed: boolean; - onNavigate?: (href: string) => void; -}): ReactNode { - if (item.hidden) return null; - - const handleClick = (e: React.MouseEvent) => { - if (onNavigate) { - e.preventDefault(); - onNavigate(item.href); - } - }; - - return ( - - {item.icon && ( - - {item.icon} - - )} - {!collapsed && {item.label}} - {!collapsed && item.badge !== undefined && ( - - {item.badge} - - )} - - ); -} - -export function Sidebar({ - productName, - logo, - version, - nav, - pathname, - features = {}, - onNavigate, - footer, - collapsed = false, - onToggleCollapse, -}: SidebarProps): ReactNode { - const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }]; - - const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/'); - - // Add built-in settings nav if enabled and not already present - const hasSettings = features.settings !== false; - const allItems = sections.flatMap(s => s.items); - const settingsExists = allItems.some(i => i.href === '/settings'); - - return ( - - ); -} diff --git a/vendor/bytelyst/dashboard-shell/src/TopBar.tsx b/vendor/bytelyst/dashboard-shell/src/TopBar.tsx deleted file mode 100644 index 9759315..0000000 --- a/vendor/bytelyst/dashboard-shell/src/TopBar.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import type { TopBarProps } from './types.js'; - -export function TopBar({ - user, - features = {}, - onSignOut, - onNavigate, - actions, - onToggleSidebar, -}: TopBarProps): ReactNode { - const [menuOpen, setMenuOpen] = useState(false); - - const handleNav = (href: string) => { - setMenuOpen(false); - if (onNavigate) onNavigate(href); - }; - - const initials = user - ? user.name - .split(' ') - .map(w => w[0]) - .join('') - .toUpperCase() - .slice(0, 2) - : '?'; - - return ( -
- {/* Left: mobile hamburger */} -
- {onToggleSidebar && ( - - )} -
- - {/* Right: actions + user menu */} -
- {actions} - - {features.notifications && ( - - )} - - {user && ( -
- - - {menuOpen && ( -
-
-
{user.name}
-
- {user.email} -
-
- - {features.profile !== false && ( - { - e.preventDefault(); - handleNav('/profile'); - }} - style={menuItemStyle} - > - Profile - - )} - {features.billing && ( - { - e.preventDefault(); - handleNav('/billing'); - }} - style={menuItemStyle} - > - Billing - - )} - {features.settings !== false && ( - { - e.preventDefault(); - handleNav('/settings'); - }} - style={menuItemStyle} - > - Settings - - )} - - {onSignOut && ( - - )} -
- )} -
- )} -
-
- ); -} - -const menuItemStyle: React.CSSProperties = { - display: 'block', - padding: '10px 16px', - fontSize: 14, - color: 'var(--color-foreground, #111827)', - textDecoration: 'none', - cursor: 'pointer', -}; diff --git a/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx b/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx deleted file mode 100644 index ebce925..0000000 --- a/vendor/bytelyst/dashboard-shell/src/__tests__/dashboard-shell.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -// @vitest-environment happy-dom -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { DashboardShell } from '../DashboardShell.js'; -import { Sidebar } from '../Sidebar.js'; -import { TopBar } from '../TopBar.js'; -import { ProfilePage } from '../ProfilePage.js'; -import { BillingPage } from '../BillingPage.js'; -import { SettingsPage } from '../SettingsPage.js'; -import type { NavItem, NavSection, ShellUser } from '../types.js'; - -const NAV: NavItem[] = [ - { href: '/dashboard', label: 'Dashboard', icon: '◈' }, - { href: '/tasks', label: 'Tasks', icon: '✓' }, - { href: '/settings', label: 'Settings', icon: '⚙' }, -]; - -const USER: ShellUser = { - id: 'u1', - name: 'Alice Smith', - email: 'alice@example.com', - role: 'admin', -}; - -// ── DashboardShell ─────────────────────────────────────────────────────────── - -describe('DashboardShell', () => { - beforeEach(() => cleanup()); - - it('renders sidebar, topbar, and content', () => { - render( - -
Page content
-
- ); - expect(screen.getByTestId('bl-dashboard-shell')).toBeDefined(); - expect(screen.getByTestId('bl-shell-sidebar')).toBeDefined(); - expect(screen.getByTestId('bl-shell-topbar')).toBeDefined(); - expect(screen.getByTestId('bl-shell-main')).toBeDefined(); - expect(screen.getByText('Page content')).toBeDefined(); - }); - - it('passes product name to sidebar', () => { - render( - -
- - ); - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('MyProduct'); - }); - - it('passes user to topbar', () => { - render( - -
- - ); - expect(screen.getByText('Alice Smith')).toBeDefined(); - }); - - it('calls onSignOut when sign out clicked', () => { - const onSignOut = vi.fn(); - render( - -
- - ); - // Open user menu - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); - expect(onSignOut).toHaveBeenCalledOnce(); - }); - - it('toggles sidebar collapse', () => { - render( - -
- - ); - const toggle = screen.getByTestId('bl-shell-collapse-toggle'); - // Initially expanded — product name shows full text - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('TestApp'); - fireEvent.click(toggle); - // Collapsed — shows first letter - expect(screen.getByTestId('bl-shell-product-name').textContent).toBe('T'); - }); -}); - -// ── Sidebar ────────────────────────────────────────────────────────────────── - -describe('Sidebar', () => { - beforeEach(() => cleanup()); - - it('renders nav items', () => { - render(); - expect(screen.getByText('Dashboard')).toBeDefined(); - expect(screen.getByText('Tasks')).toBeDefined(); - }); - - it('highlights active nav item', () => { - render(); - const dashLink = screen.getByTestId('bl-nav-dashboard'); - expect(dashLink.style.fontWeight).toBe('600'); - }); - - it('calls onNavigate when item clicked', () => { - const onNavigate = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-nav-tasks')); - expect(onNavigate).toHaveBeenCalledWith('/tasks'); - }); - - it('supports NavSection format', () => { - const sections: NavSection[] = [ - { title: 'Main', items: [{ href: '/home', label: 'Home' }] }, - { title: 'Admin', items: [{ href: '/admin', label: 'Admin Panel' }] }, - ]; - render(); - expect(screen.getByText('Main')).toBeDefined(); - expect(screen.getByText('Admin')).toBeDefined(); - expect(screen.getByText('Home')).toBeDefined(); - expect(screen.getByText('Admin Panel')).toBeDefined(); - }); - - it('hides hidden nav items', () => { - const items: NavItem[] = [ - { href: '/visible', label: 'Visible' }, - { href: '/hidden', label: 'Hidden', hidden: true }, - ]; - render(); - expect(screen.getByText('Visible')).toBeDefined(); - expect(screen.queryByText('Hidden')).toBeNull(); - }); - - it('shows badge on nav item', () => { - const items: NavItem[] = [{ href: '/inbox', label: 'Inbox', badge: 5 }]; - render(); - expect(screen.getByText('5')).toBeDefined(); - }); - - it('renders version in footer', () => { - render(); - expect(screen.getByTestId('bl-shell-sidebar-footer').textContent).toContain('v1.2.3'); - }); - - it('renders custom footer', () => { - render(Custom} />); - expect(screen.getByText('Custom')).toBeDefined(); - }); - - it('renders logo instead of product name', () => { - render( - Logo} - /> - ); - expect(screen.getByTestId('logo')).toBeDefined(); - }); - - it('auto-adds settings link when not present', () => { - const items: NavItem[] = [{ href: '/dashboard', label: 'Dashboard' }]; - render( - - ); - expect(screen.getByTestId('bl-nav-settings')).toBeDefined(); - }); - - it('does not duplicate settings link when already present', () => { - render(); - const settingsLinks = screen.getAllByText('Settings'); - expect(settingsLinks.length).toBe(1); - }); -}); - -// ── TopBar ─────────────────────────────────────────────────────────────────── - -describe('TopBar', () => { - beforeEach(() => cleanup()); - - it('renders user name', () => { - render(); - expect(screen.getByText('Alice Smith')).toBeDefined(); - }); - - it('renders initials avatar when no avatarUrl', () => { - render(); - expect(screen.getByTestId('bl-shell-user-avatar').textContent).toBe('AS'); - }); - - it('opens and closes user menu', () => { - render(); - expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-user-menu')).toBeDefined(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.queryByTestId('bl-shell-user-menu')).toBeNull(); - }); - - it('shows profile link in menu by default', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-menu-profile')).toBeDefined(); - }); - - it('shows billing link when feature enabled', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.getByTestId('bl-shell-menu-billing')).toBeDefined(); - }); - - it('hides billing link when feature disabled', () => { - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - expect(screen.queryByTestId('bl-shell-menu-billing')).toBeNull(); - }); - - it('shows notifications bell when enabled', () => { - render(); - expect(screen.getByTestId('bl-shell-notifications')).toBeDefined(); - }); - - it('calls onSignOut', () => { - const onSignOut = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-signout')); - expect(onSignOut).toHaveBeenCalledOnce(); - }); - - it('calls onNavigate for menu items', () => { - const onNavigate = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-user-menu-trigger')); - fireEvent.click(screen.getByTestId('bl-shell-menu-profile')); - expect(onNavigate).toHaveBeenCalledWith('/profile'); - }); - - it('renders custom actions', () => { - render(Action} />); - expect(screen.getByTestId('custom-action')).toBeDefined(); - }); - - it('shows hamburger when onToggleSidebar provided', () => { - const toggle = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-shell-hamburger')); - expect(toggle).toHaveBeenCalledOnce(); - }); -}); - -// ── ProfilePage ────────────────────────────────────────────────────────────── - -describe('ProfilePage', () => { - beforeEach(() => cleanup()); - - it('renders user info', () => { - render(); - expect(screen.getByTestId('bl-shell-profile-page')).toBeDefined(); - expect(screen.getByTestId('bl-profile-avatar')).toBeDefined(); - }); - - it('pre-fills form fields', () => { - render(); - const nameInput = screen.getByTestId('bl-profile-name') as unknown as { value: string }; - const emailInput = screen.getByTestId('bl-profile-email') as unknown as { value: string }; - expect(nameInput.value).toBe('Alice Smith'); - expect(emailInput.value).toBe('alice@example.com'); - }); - - it('calls onUpdateProfile with form data', () => { - const onUpdate = vi.fn(); - render(); - fireEvent.change(screen.getByTestId('bl-profile-name'), { target: { value: 'Bob Jones' } }); - fireEvent.submit(screen.getByTestId('bl-profile-submit').closest('form')!); - expect(onUpdate).toHaveBeenCalledWith({ name: 'Bob Jones', email: 'alice@example.com' }); - }); - - it('shows loading state', () => { - render(); - expect(screen.getByText('Saving...')).toBeDefined(); - }); - - it('shows error and success messages', () => { - const { rerender } = render(); - expect(screen.getByTestId('bl-profile-error')).toBeDefined(); - - rerender(); - expect(screen.getByTestId('bl-profile-success')).toBeDefined(); - }); - - it('shows role when present', () => { - render(); - expect(screen.getByText('Role: admin')).toBeDefined(); - }); -}); - -// ── BillingPage ────────────────────────────────────────────────────────────── - -describe('BillingPage', () => { - beforeEach(() => cleanup()); - - it('renders current plan', () => { - render(); - expect(screen.getByTestId('bl-shell-billing-page')).toBeDefined(); - expect(screen.getByText('Pro')).toBeDefined(); - }); - - it('shows status badge', () => { - render(); - expect(screen.getByTestId('bl-billing-status').textContent).toBe('trialing'); - }); - - it('shows trial end date', () => { - render(); - expect(screen.getByTestId('bl-billing-trial').textContent).toContain('2026-04-01'); - }); - - it('calls onManageBilling', () => { - const onManage = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('bl-billing-manage')); - expect(onManage).toHaveBeenCalledOnce(); - }); - - it('renders plan comparison grid', () => { - const plans = [ - { name: 'Free', price: '$0/mo', features: ['Basic features'], current: true }, - { name: 'Pro', price: '$9/mo', features: ['All features', 'Priority support'] }, - ]; - render(); - expect(screen.getByTestId('bl-billing-plans')).toBeDefined(); - expect(screen.getByTestId('bl-billing-plan-free')).toBeDefined(); - expect(screen.getByTestId('bl-billing-plan-pro')).toBeDefined(); - expect(screen.getByText('$9/mo')).toBeDefined(); - }); - - it('defaults to Free plan when not specified', () => { - render(); - expect(screen.getByText('Free')).toBeDefined(); - }); -}); - -// ── SettingsPage ───────────────────────────────────────────────────────────── - -describe('SettingsPage', () => { - beforeEach(() => cleanup()); - - it('renders empty state when no sections', () => { - render(); - expect(screen.getByTestId('bl-shell-settings-page')).toBeDefined(); - expect(screen.getByTestId('bl-settings-empty')).toBeDefined(); - expect(screen.getByText('No settings configured for TestApp.')).toBeDefined(); - }); - - it('renders sections', () => { - const sections = [ - { title: 'Notifications', description: 'Manage alerts', content:
Toggle
}, - { title: 'Theme', content:
Dark mode
}, - ]; - render(); - expect(screen.getByTestId('bl-settings-section-0')).toBeDefined(); - expect(screen.getByTestId('bl-settings-section-1')).toBeDefined(); - expect(screen.getByText('Notifications')).toBeDefined(); - expect(screen.getByText('Manage alerts')).toBeDefined(); - expect(screen.getByText('Toggle')).toBeDefined(); - expect(screen.getByText('Dark mode')).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/dashboard-shell/src/index.ts b/vendor/bytelyst/dashboard-shell/src/index.ts deleted file mode 100644 index 70bef60..0000000 --- a/vendor/bytelyst/dashboard-shell/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @bytelyst/dashboard-shell - * - * Configurable Next.js dashboard layout with sidebar, top bar, - * and built-in pages for profile, billing, and settings. - * - * All components read CSS custom properties (--bl-shell-*, --color-*) - * with sensible fallback defaults. - */ - -export { DashboardShell } from './DashboardShell.js'; -export { Sidebar } from './Sidebar.js'; -export { TopBar } from './TopBar.js'; -export { ProfilePage } from './ProfilePage.js'; -export { BillingPage } from './BillingPage.js'; -export { SettingsPage } from './SettingsPage.js'; - -export type { - DashboardShellProps, - SidebarProps, - TopBarProps, - NavItem, - NavSection, - ShellUser, - ShellFeatures, - ProfilePageProps, - BillingPageProps, - SettingsPageProps, - SettingsSection, -} from './types.js'; diff --git a/vendor/bytelyst/dashboard-shell/src/types.ts b/vendor/bytelyst/dashboard-shell/src/types.ts deleted file mode 100644 index 14a3e98..0000000 --- a/vendor/bytelyst/dashboard-shell/src/types.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { ReactNode } from 'react'; - -// ── Navigation ─────────────────────────────────────────────────────────────── - -export interface NavItem { - href: string; - label: string; - icon?: ReactNode; - badge?: string | number; - /** Hide this item from nav (useful for feature-flag gating) */ - hidden?: boolean; -} - -export interface NavSection { - title?: string; - items: NavItem[]; -} - -// ── User ───────────────────────────────────────────────────────────────────── - -export interface ShellUser { - id: string; - name: string; - email: string; - avatarUrl?: string; - role?: string; -} - -// ── Features ───────────────────────────────────────────────────────────────── - -export interface ShellFeatures { - /** Show profile page link in user menu (default: true) */ - profile?: boolean; - /** Show billing page link in user menu (default: false) */ - billing?: boolean; - /** Show settings page link in sidebar (default: true) */ - settings?: boolean; - /** Show notifications icon in top bar (default: false) */ - notifications?: boolean; - /** Show dark/light theme toggle (default: false) */ - themeToggle?: boolean; -} - -// ── Shell Config ───────────────────────────────────────────────────────────── - -export interface DashboardShellProps { - /** Product display name shown in sidebar header */ - productName: string; - /** Product logo element (replaces text name if provided) */ - logo?: ReactNode; - /** Product version shown in sidebar footer */ - version?: string; - /** Navigation items or sections */ - nav: NavItem[] | NavSection[]; - /** Current pathname for active state (if not using internal detection) */ - pathname?: string; - /** Currently logged-in user */ - user?: ShellUser; - /** Feature toggles for built-in pages */ - features?: ShellFeatures; - /** Called when user clicks Sign Out */ - onSignOut?: () => void; - /** Called when a nav item is clicked (for SPA routers) */ - onNavigate?: (href: string) => void; - /** Sidebar footer content (replaces default) */ - sidebarFooter?: ReactNode; - /** Content to render in the top bar (right side) */ - topBarActions?: ReactNode; - /** Dashboard page content */ - children: ReactNode; -} - -// ── Sidebar Props ──────────────────────────────────────────────────────────── - -export interface SidebarProps { - productName: string; - logo?: ReactNode; - version?: string; - nav: NavItem[] | NavSection[]; - pathname: string; - features?: ShellFeatures; - onNavigate?: (href: string) => void; - footer?: ReactNode; - collapsed?: boolean; - onToggleCollapse?: () => void; -} - -// ── Top Bar Props ──────────────────────────────────────────────────────────── - -export interface TopBarProps { - user?: ShellUser; - features?: ShellFeatures; - onSignOut?: () => void; - onNavigate?: (href: string) => void; - actions?: ReactNode; - onToggleSidebar?: () => void; -} - -// ── Built-in Page Props ────────────────────────────────────────────────────── - -export interface ProfilePageProps { - user: ShellUser; - onUpdateProfile?: (data: { name: string; email: string }) => void; - isLoading?: boolean; - error?: string; - success?: string; -} - -export interface BillingPageProps { - currentPlan?: string; - status?: 'active' | 'trialing' | 'past_due' | 'canceled'; - trialEndsAt?: string; - onManageBilling?: () => void; - plans?: Array<{ - name: string; - price: string; - features: string[]; - current?: boolean; - }>; -} - -export interface SettingsPageProps { - productName: string; - sections?: SettingsSection[]; -} - -export interface SettingsSection { - title: string; - description?: string; - content: ReactNode; -} diff --git a/vendor/bytelyst/dashboard-shell/tsconfig.json b/vendor/bytelyst/dashboard-shell/tsconfig.json deleted file mode 100644 index 3128359..0000000 --- a/vendor/bytelyst/dashboard-shell/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "jsx": "react-jsx", - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.*"] -} diff --git a/vendor/bytelyst/dashboard-shell/vitest.config.ts b/vendor/bytelyst/dashboard-shell/vitest.config.ts deleted file mode 100644 index cf32686..0000000 --- a/vendor/bytelyst/dashboard-shell/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/datastore/package.json b/vendor/bytelyst/datastore/package.json deleted file mode 100644 index ed43c01..0000000 --- a/vendor/bytelyst/datastore/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@bytelyst/datastore", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@azure/cosmos": ">=4.0.0" - }, - "peerDependenciesMeta": { - "@azure/cosmos": { - "optional": true - } - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/datastore/src/__tests__/memory.test.ts b/vendor/bytelyst/datastore/src/__tests__/memory.test.ts deleted file mode 100644 index 9652d2f..0000000 --- a/vendor/bytelyst/datastore/src/__tests__/memory.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Tests for MemoryDatastoreProvider and filter evaluation. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryDatastoreProvider } from '../providers/memory.js'; -import { matchesFilter, filterToCosmosSQL } from '../filter.js'; -import type { BaseDocument, DocumentCollection } from '../types.js'; - -interface TestDoc extends BaseDocument { - name: string; - price?: number; - tags?: string[]; - createdAt?: string; -} - -describe('MemoryDatastoreProvider', () => { - let provider: MemoryDatastoreProvider; - let collection: DocumentCollection; - - beforeEach(() => { - provider = new MemoryDatastoreProvider(); - collection = provider.getCollection('test', '/productId'); - }); - - it('isHealthy returns true', async () => { - expect(await provider.isHealthy()).toBe(true); - }); - - it('returns same collection instance for same name', () => { - const c1 = provider.getCollection('test', '/productId'); - const c2 = provider.getCollection('test', '/productId'); - expect(c1).toBe(c2); - }); - - describe('CRUD operations', () => { - const doc: TestDoc = { id: '1', productId: 'test', name: 'Widget', price: 10 }; - - it('create + findById', async () => { - const created = await collection.create(doc); - expect(created).toEqual(doc); - const found = await collection.findById('1', 'test'); - expect(found).toEqual(doc); - }); - - it('create throws on duplicate', async () => { - await collection.create(doc); - await expect(collection.create(doc)).rejects.toThrow('already exists'); - }); - - it('findById returns null for missing', async () => { - expect(await collection.findById('missing', 'test')).toBeNull(); - }); - - it('update merges fields', async () => { - await collection.create(doc); - const updated = await collection.update('1', 'test', { price: 20 }); - expect(updated.price).toBe(20); - expect(updated.name).toBe('Widget'); - }); - - it('update throws for missing doc', async () => { - await expect(collection.update('missing', 'test', { price: 20 })).rejects.toThrow( - 'not found' - ); - }); - - it('upsert creates or replaces', async () => { - const created = await collection.upsert(doc); - expect(created).toEqual(doc); - const replaced = await collection.upsert({ ...doc, name: 'Gadget' }); - expect(replaced.name).toBe('Gadget'); - }); - - it('delete removes doc', async () => { - await collection.create(doc); - await collection.delete('1', 'test'); - expect(await collection.findById('1', 'test')).toBeNull(); - }); - }); - - describe('findMany', () => { - const docs: TestDoc[] = [ - { id: '1', productId: 'p1', name: 'Alpha', price: 30, createdAt: '2026-01-01' }, - { id: '2', productId: 'p1', name: 'Beta', price: 10, createdAt: '2026-01-02' }, - { id: '3', productId: 'p1', name: 'Gamma', price: 20, createdAt: '2026-01-03' }, - { id: '4', productId: 'p2', name: 'Delta', price: 15, createdAt: '2026-01-04' }, - ]; - - beforeEach(async () => { - for (const d of docs) await collection.create(d); - }); - - it('returns all without query', async () => { - const results = await collection.findMany(); - expect(results).toHaveLength(4); - }); - - it('filters by exact match', async () => { - const results = await collection.findMany({ filter: { productId: 'p1' } }); - expect(results).toHaveLength(3); - }); - - it('filters with $gt', async () => { - const results = await collection.findMany({ filter: { price: { $gt: 15 } } }); - expect(results).toHaveLength(2); - }); - - it('filters with $in', async () => { - const results = await collection.findMany({ filter: { name: { $in: ['Alpha', 'Gamma'] } } }); - expect(results).toHaveLength(2); - }); - - it('sorts ascending', async () => { - const results = await collection.findMany({ sort: { price: 1 } }); - expect(results.map(d => d.price)).toEqual([10, 15, 20, 30]); - }); - - it('sorts descending', async () => { - const results = await collection.findMany({ sort: { price: -1 } }); - expect(results.map(d => d.price)).toEqual([30, 20, 15, 10]); - }); - - it('limits results', async () => { - const results = await collection.findMany({ limit: 2 }); - expect(results).toHaveLength(2); - }); - - it('offsets + limits', async () => { - const all = await collection.findMany({ sort: { price: 1 } }); - const page2 = await collection.findMany({ sort: { price: 1 }, offset: 2, limit: 2 }); - expect(page2).toEqual(all.slice(2, 4)); - }); - - it('selects specific fields', async () => { - const results = await collection.findMany({ select: ['id', 'name'] }); - expect(results[0]).toHaveProperty('id'); - expect(results[0]).toHaveProperty('name'); - expect(results[0]).not.toHaveProperty('price'); - }); - }); - - describe('findOne', () => { - it('returns first match', async () => { - await collection.create({ id: '1', productId: 'p', name: 'A' }); - await collection.create({ id: '2', productId: 'p', name: 'B' }); - const result = await collection.findOne({ filter: { productId: 'p' } }); - expect(result).not.toBeNull(); - }); - - it('returns null when no match', async () => { - const result = await collection.findOne({ filter: { productId: 'missing' } }); - expect(result).toBeNull(); - }); - }); - - describe('count', () => { - it('counts all without filter', async () => { - await collection.create({ id: '1', productId: 'p', name: 'A' }); - await collection.create({ id: '2', productId: 'p', name: 'B' }); - expect(await collection.count()).toBe(2); - }); - - it('counts with filter', async () => { - await collection.create({ id: '1', productId: 'p1', name: 'A' }); - await collection.create({ id: '2', productId: 'p2', name: 'B' }); - expect(await collection.count({ productId: 'p1' })).toBe(1); - }); - }); - - describe('aggregate', () => { - it('groups and aggregates', async () => { - await collection.create({ id: '1', productId: 'p1', name: 'A', price: 10 }); - await collection.create({ id: '2', productId: 'p1', name: 'B', price: 20 }); - await collection.create({ id: '3', productId: 'p2', name: 'C', price: 30 }); - - const results = await collection.aggregate<{ productId: string; total: number; cnt: number }>( - { - groupBy: 'productId', - aggregations: [ - { field: 'price', op: 'sum', alias: 'total' }, - { field: 'price', op: 'count', alias: 'cnt' }, - ], - } - ); - - expect(results).toHaveLength(2); - const p1 = results.find(r => r.productId === 'p1'); - expect(p1?.total).toBe(30); - expect(p1?.cnt).toBe(2); - }); - }); - - describe('rawQuery', () => { - it('throws for memory provider', async () => { - await expect(collection.rawQuery('SELECT * FROM c')).rejects.toThrow('not supported'); - }); - }); -}); - -describe('matchesFilter', () => { - const doc = { id: '1', productId: 'p', name: 'test', price: 25, tags: ['a', 'b'] }; - - it('exact match', () => { - expect(matchesFilter(doc, { name: 'test' })).toBe(true); - expect(matchesFilter(doc, { name: 'other' })).toBe(false); - }); - - it('$ne', () => { - expect(matchesFilter(doc, { price: { $ne: 30 } })).toBe(true); - expect(matchesFilter(doc, { price: { $ne: 25 } })).toBe(false); - }); - - it('$exists', () => { - expect(matchesFilter(doc, { price: { $exists: true } })).toBe(true); - expect(matchesFilter(doc, { missing: { $exists: false } })).toBe(true); - expect(matchesFilter(doc, { price: { $exists: false } })).toBe(false); - }); - - it('$startsWith', () => { - expect(matchesFilter(doc, { name: { $startsWith: 'te' } })).toBe(true); - expect(matchesFilter(doc, { name: { $startsWith: 'no' } })).toBe(false); - }); - - it('$contains on string', () => { - expect(matchesFilter(doc, { name: { $contains: 'es' } })).toBe(true); - }); - - it('$contains on array', () => { - expect(matchesFilter(doc, { tags: { $contains: 'a' } })).toBe(true); - expect(matchesFilter(doc, { tags: { $contains: 'z' } })).toBe(false); - }); - - it('$or', () => { - expect(matchesFilter(doc, { $or: [{ name: 'test' }, { name: 'other' }] })).toBe(true); - expect(matchesFilter(doc, { $or: [{ name: 'x' }, { name: 'y' }] })).toBe(false); - }); - - it('combined conditions', () => { - expect(matchesFilter(doc, { productId: 'p', price: { $gte: 20, $lt: 30 } })).toBe(true); - expect(matchesFilter(doc, { productId: 'p', price: { $gte: 30 } })).toBe(false); - }); -}); - -describe('filterToCosmosSQL', () => { - it('exact match', () => { - const result = filterToCosmosSQL({ name: 'test' }); - expect(result.query).toBe('WHERE c.name = @p0'); - expect(result.parameters).toEqual([{ name: '@p0', value: 'test' }]); - }); - - it('comparison operators', () => { - const result = filterToCosmosSQL({ price: { $gte: 10, $lt: 50 } }); - expect(result.query).toContain('c.price >= @p0'); - expect(result.query).toContain('c.price < @p1'); - }); - - it('$exists', () => { - const result = filterToCosmosSQL({ deleted: { $exists: false } }); - expect(result.query).toContain('NOT IS_DEFINED(c.deleted)'); - }); - - it('$in', () => { - const result = filterToCosmosSQL({ status: { $in: ['active', 'pending'] } }); - expect(result.query).toContain('c.status IN'); - expect(result.parameters).toHaveLength(2); - }); - - it('$contains emits ARRAY_CONTAINS OR CONTAINS', () => { - const result = filterToCosmosSQL({ tags: { $contains: 'urgent' } }); - expect(result.query).toContain('ARRAY_CONTAINS(c.tags, @p0)'); - expect(result.query).toContain('CONTAINS(c.tags, @p0)'); - expect(result.parameters).toEqual([{ name: '@p0', value: 'urgent' }]); - }); - - it('$or', () => { - const result = filterToCosmosSQL({ $or: [{ a: 1 }, { b: 2 }] }); - expect(result.query).toContain('OR'); - }); - - it('empty filter returns empty WHERE', () => { - const result = filterToCosmosSQL({}); - expect(result.query).toBe(''); - }); -}); diff --git a/vendor/bytelyst/datastore/src/factory.ts b/vendor/bytelyst/datastore/src/factory.ts deleted file mode 100644 index 3aa0ff6..0000000 --- a/vendor/bytelyst/datastore/src/factory.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Datastore provider factory. - * - * Creates a DatastoreProvider based on DB_PROVIDER env var or explicit type. - * Defaults to 'cosmos' for backward compatibility. - */ - -import { MemoryDatastoreProvider } from './providers/memory.js'; -import type { DatastoreProvider, DatastoreProviderType } from './types.js'; - -let _provider: DatastoreProvider | null = null; - -/** - * Get the singleton datastore provider. - * Lazily creates on first call based on DB_PROVIDER env var. - * - * - 'cosmos' (default) — Azure Cosmos DB - * - 'memory' — In-memory (for testing) - */ -export async function getDatastore(): Promise { - if (!_provider) { - const providerType = (process.env.DB_PROVIDER || 'cosmos') as DatastoreProviderType; - _provider = await createDatastoreProvider(providerType); - } - return _provider; -} - -/** - * Create a datastore provider by type. - */ -export async function createDatastoreProvider( - type: DatastoreProviderType -): Promise { - switch (type) { - case 'cosmos': { - const { CosmosDatastoreProvider } = await import('./providers/cosmos.js'); - return new CosmosDatastoreProvider(); - } - case 'memory': - return new MemoryDatastoreProvider(); - default: - throw new Error(`Unknown DB_PROVIDER: '${type}'. Valid: cosmos, memory`); - } -} - -/** - * Set the singleton datastore provider directly (for testing or manual wiring). - */ -export function setDatastore(provider: DatastoreProvider): void { - _provider = provider; -} - -/** - * Reset the singleton (for testing). - * @internal - */ -export function _resetDatastore(): void { - _provider = null; -} diff --git a/vendor/bytelyst/datastore/src/filter.ts b/vendor/bytelyst/datastore/src/filter.ts deleted file mode 100644 index 6ac78ae..0000000 --- a/vendor/bytelyst/datastore/src/filter.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * FilterMap evaluation utilities. - * - * Used by memory provider for in-memory filtering and - * by cosmos provider for SQL query generation. - */ - -import type { FilterMap, FilterOperator, FilterValue } from './types.js'; - -/** - * Evaluate a FilterMap against a document (in-memory). - * Returns true if the document matches all filter conditions. - */ -export function matchesFilter(doc: Record, filter: FilterMap): boolean { - for (const [key, condition] of Object.entries(filter)) { - if (key === '$or') { - const orClauses = condition as FilterMap[]; - if (!orClauses.some(clause => matchesFilter(doc, clause))) return false; - continue; - } - if (condition === undefined) continue; - - const value = getNestedValue(doc, key); - - if (isFilterOperator(condition)) { - if (!matchesOperator(value, condition as FilterOperator)) return false; - } else { - // Exact match - if (value !== condition) return false; - } - } - return true; -} - -function getNestedValue(obj: Record, path: string): unknown { - const parts = path.split('.'); - let current: unknown = obj; - for (const part of parts) { - if (current == null || typeof current !== 'object') return undefined; - current = (current as Record)[part]; - } - return current; -} - -function isFilterOperator(value: unknown): value is FilterOperator { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const keys = Object.keys(value); - return keys.some(k => k.startsWith('$')); -} - -function matchesOperator(docValue: unknown, op: FilterOperator): boolean { - if (op.$gt !== undefined && !(compare(docValue, op.$gt) > 0)) return false; - if (op.$gte !== undefined && !(compare(docValue, op.$gte) >= 0)) return false; - if (op.$lt !== undefined && !(compare(docValue, op.$lt) < 0)) return false; - if (op.$lte !== undefined && !(compare(docValue, op.$lte) <= 0)) return false; - if (op.$ne !== undefined && docValue === op.$ne) return false; - - if (op.$exists !== undefined) { - const exists = docValue !== undefined && docValue !== null; - if (op.$exists !== exists) return false; - } - - if (op.$startsWith !== undefined) { - if (typeof docValue !== 'string') return false; - if (!docValue.startsWith(op.$startsWith)) return false; - } - - if (op.$contains !== undefined) { - if (Array.isArray(docValue)) { - if (!docValue.includes(op.$contains)) return false; - } else if (typeof docValue === 'string' && typeof op.$contains === 'string') { - if (!docValue.includes(op.$contains)) return false; - } else { - return false; - } - } - - if (op.$in !== undefined) { - if (!op.$in.includes(docValue as FilterValue)) return false; - } - - return true; -} - -function compare(a: unknown, b: unknown): number { - if (a === b) return 0; - if (a == null) return -1; - if (b == null) return 1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); - if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0); - return String(a).localeCompare(String(b)); -} - -// ── Cosmos SQL generation ─────────────────────────────────────────────────── - -export interface SqlQuery { - query: string; - parameters: Array<{ name: string; value: unknown }>; -} - -/** - * Convert a FilterMap to a Cosmos SQL WHERE clause. - */ -export function filterToCosmosSQL(filter: FilterMap): SqlQuery { - const parameters: Array<{ name: string; value: unknown }> = []; - let paramIndex = 0; - - function nextParam(value: unknown): string { - const name = `@p${paramIndex++}`; - parameters.push({ name, value }); - return name; - } - - function buildCondition(key: string, condition: unknown): string { - if (condition === null) { - return `c.${key} = null`; - } - - if (!isFilterOperator(condition)) { - return `c.${key} = ${nextParam(condition)}`; - } - - const op = condition as FilterOperator; - const parts: string[] = []; - - if (op.$gt !== undefined) parts.push(`c.${key} > ${nextParam(op.$gt)}`); - if (op.$gte !== undefined) parts.push(`c.${key} >= ${nextParam(op.$gte)}`); - if (op.$lt !== undefined) parts.push(`c.${key} < ${nextParam(op.$lt)}`); - if (op.$lte !== undefined) parts.push(`c.${key} <= ${nextParam(op.$lte)}`); - if (op.$ne !== undefined) parts.push(`c.${key} != ${nextParam(op.$ne)}`); - - if (op.$exists !== undefined) { - parts.push(op.$exists ? `IS_DEFINED(c.${key})` : `NOT IS_DEFINED(c.${key})`); - } - - if (op.$startsWith !== undefined) { - parts.push(`STARTSWITH(c.${key}, ${nextParam(op.$startsWith)})`); - } - - if (op.$contains !== undefined) { - // Could be array or string — emit both ARRAY_CONTAINS and CONTAINS. - // Cosmos returns false (not error) when the function doesn't match the field type, - // so OR-ing them handles both array membership and string substring correctly. - const param = nextParam(op.$contains); - parts.push(`(ARRAY_CONTAINS(c.${key}, ${param}) OR CONTAINS(c.${key}, ${param}))`); - } - - if (op.$in !== undefined) { - const placeholders = op.$in.map(v => nextParam(v)); - parts.push(`c.${key} IN (${placeholders.join(', ')})`); - } - - return parts.join(' AND '); - } - - function buildFilter(f: FilterMap): string { - const clauses: string[] = []; - - for (const [key, condition] of Object.entries(f)) { - if (key === '$or') { - const orClauses = (condition as FilterMap[]).map(c => `(${buildFilter(c)})`); - clauses.push(`(${orClauses.join(' OR ')})`); - continue; - } - if (condition === undefined) continue; - clauses.push(buildCondition(key, condition)); - } - - return clauses.join(' AND '); - } - - const where = buildFilter(filter); - return { - query: where ? `WHERE ${where}` : '', - parameters, - }; -} diff --git a/vendor/bytelyst/datastore/src/index.ts b/vendor/bytelyst/datastore/src/index.ts deleted file mode 100644 index e0718b8..0000000 --- a/vendor/bytelyst/datastore/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type { - BaseDocument, - FilterMap, - FilterValue, - FilterOperator, - FilterCondition, - CollectionQuery, - AggregateQuery, - AggregationField, - DocumentCollection, - DatastoreProvider, - DatastoreProviderType, -} from './types.js'; - -export { getDatastore, createDatastoreProvider, setDatastore, _resetDatastore } from './factory.js'; -export { CosmosDatastoreProvider, type CosmosProviderConfig } from './providers/cosmos.js'; -export { MemoryDatastoreProvider } from './providers/memory.js'; -export { matchesFilter, filterToCosmosSQL } from './filter.js'; diff --git a/vendor/bytelyst/datastore/src/providers/cosmos.ts b/vendor/bytelyst/datastore/src/providers/cosmos.ts deleted file mode 100644 index 059f9d8..0000000 --- a/vendor/bytelyst/datastore/src/providers/cosmos.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Azure Cosmos DB datastore provider. - * - * Wraps @azure/cosmos SDK behind the cloud-agnostic DocumentCollection interface. - * Translates FilterMap queries to Cosmos SQL. - */ - -import { filterToCosmosSQL } from '../filter.js'; -import type { - AggregateQuery, - BaseDocument, - CollectionQuery, - DatastoreProvider, - DocumentCollection, - FilterMap, -} from '../types.js'; - -export interface CosmosProviderConfig { - endpoint: string; - key: string; - database: string; -} - -export class CosmosDatastoreProvider implements DatastoreProvider { - private client: import('@azure/cosmos').CosmosClient | null = null; - private databaseRef: import('@azure/cosmos').Database | null = null; - private config: CosmosProviderConfig; - private collections = new Map>(); - - constructor(config?: CosmosProviderConfig) { - this.config = config ?? { - endpoint: getEnvOrThrow('COSMOS_ENDPOINT'), - key: getEnvOrThrow('COSMOS_KEY'), - database: process.env.COSMOS_DATABASE || 'lysnrai', - }; - } - - private async getDatabase() { - if (!this.databaseRef) { - const { CosmosClient } = await import('@azure/cosmos'); - this.client = new CosmosClient({ - endpoint: this.config.endpoint, - key: this.config.key, - }); - this.databaseRef = this.client.database(this.config.database); - } - return this.databaseRef as import('@azure/cosmos').Database; - } - - getCollection( - name: string, - partitionKeyPath: string - ): DocumentCollection { - const cacheKey = `${name}:${partitionKeyPath}`; - let collection = this.collections.get(cacheKey); - if (!collection) { - collection = new CosmosCollection(name, partitionKeyPath, () => - this.getDatabase() - ); - this.collections.set(cacheKey, collection); - } - return collection as unknown as DocumentCollection; - } - - async isHealthy(): Promise { - try { - const db = await this.getDatabase(); - await db.read(); - return true; - } catch { - return false; - } - } -} - -class CosmosCollection implements DocumentCollection { - constructor( - private containerName: string, - private partitionKeyPath: string, - private getDatabase: () => Promise - ) {} - - private async container() { - const db = await this.getDatabase(); - return db.container(this.containerName); - } - - private pkField(): string { - // Convert /userId to userId, /id to id, etc. - return this.partitionKeyPath.replace(/^\//, ''); - } - - async findById(id: string, partitionKey: string): Promise { - try { - const c = await this.container(); - const { resource } = await c.item(id, partitionKey).read(); - return resource ?? null; - } catch (err: unknown) { - if ((err as { code?: number })?.code === 404) return null; - throw err; - } - } - - async findMany(query?: CollectionQuery): Promise { - const c = await this.container(); - const sql = this.buildSelectSQL(query); - const { resources } = await c.items - .query({ - query: sql.query, - parameters: sql.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - async findOne(query?: CollectionQuery): Promise { - const results = await this.findMany({ ...query, limit: 1 }); - return results[0] ?? null; - } - - async count(filter?: FilterMap): Promise { - const c = await this.container(); - const whereClause = filter ? filterToCosmosSQL(filter) : { query: '', parameters: [] }; - const { resources } = await c.items - .query({ - query: `SELECT VALUE COUNT(1) FROM c ${whereClause.query}`, - parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources[0] ?? 0; - } - - async create(doc: T): Promise { - const c = await this.container(); - const { resource } = await c.items.create(doc); - return resource as T; - } - - async update(id: string, partitionKey: string, updates: Partial): Promise { - const c = await this.container(); - const { resource: existing } = await c.item(id, partitionKey).read(); - if (!existing) throw new Error(`Document '${id}' not found`); - const merged = { ...existing, ...updates } as T; - const { resource } = await c.item(id, partitionKey).replace(merged); - return resource as T; - } - - async upsert(doc: T): Promise { - const c = await this.container(); - const { resource } = await c.items.upsert(doc); - return resource as T; - } - - async delete(id: string, partitionKey: string): Promise { - const c = await this.container(); - await c.item(id, partitionKey).delete(); - } - - async aggregate>(query: AggregateQuery): Promise { - const c = await this.container(); - const whereClause = query.filter - ? filterToCosmosSQL(query.filter) - : { query: '', parameters: [] }; - - const aggFields = query.aggregations - .map(a => { - const func = a.op === 'count' ? `COUNT(1)` : `${a.op.toUpperCase()}(c.${a.field})`; - return `${func} AS ${a.alias}`; - }) - .join(', '); - - const { resources } = await c.items - .query({ - query: `SELECT c.${query.groupBy}, ${aggFields} FROM c ${whereClause.query} GROUP BY c.${query.groupBy}`, - parameters: whereClause.parameters as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - async rawQuery(query: string, parameters?: Record): Promise { - const c = await this.container(); - const cosmosParams = parameters - ? Object.entries(parameters).map(([name, value]) => ({ - name: name.startsWith('@') ? name : `@${name}`, - value, - })) - : []; - const { resources } = await c.items - .query({ - query, - parameters: cosmosParams as import('@azure/cosmos').SqlParameter[], - }) - .fetchAll(); - return resources; - } - - private buildSelectSQL(query?: CollectionQuery): { - query: string; - parameters: Array<{ name: string; value: unknown }>; - } { - const parts: string[] = []; - - // SELECT - if (query?.select && query.select.length > 0) { - const fields = query.select.map(f => `c.${f as string}`).join(', '); - parts.push(`SELECT ${fields} FROM c`); - } else { - parts.push('SELECT * FROM c'); - } - - // WHERE - const whereClause = query?.filter - ? filterToCosmosSQL(query.filter) - : { query: '', parameters: [] }; - const parameters = [...whereClause.parameters]; - if (whereClause.query) parts.push(whereClause.query); - - // ORDER BY - if (query?.sort) { - const orderParts = Object.entries(query.sort) - .map(([field, dir]) => `c.${field} ${dir === 1 ? 'ASC' : 'DESC'}`) - .join(', '); - if (orderParts) parts.push(`ORDER BY ${orderParts}`); - } - - // OFFSET / LIMIT - if (query?.offset !== undefined && query?.limit !== undefined) { - parts.push(`OFFSET ${query.offset} LIMIT ${query.limit}`); - } else if (query?.limit !== undefined) { - parts.push(`OFFSET 0 LIMIT ${query.limit}`); - } - - return { query: parts.join(' '), parameters }; - } -} - -function getEnvOrThrow(name: string): string { - const value = process.env[name]; - if (!value) - throw new Error(`Environment variable ${name} is required for CosmosDatastoreProvider`); - return value; -} diff --git a/vendor/bytelyst/datastore/src/providers/memory.ts b/vendor/bytelyst/datastore/src/providers/memory.ts deleted file mode 100644 index 9d1cc3f..0000000 --- a/vendor/bytelyst/datastore/src/providers/memory.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * In-memory datastore provider — for testing and local dev. - * - * All data is stored in Maps. No persistence. Fast and deterministic. - */ - -import { matchesFilter } from '../filter.js'; -import type { - AggregateQuery, - BaseDocument, - CollectionQuery, - DatastoreProvider, - DocumentCollection, - FilterMap, -} from '../types.js'; - -function deepClone(obj: T): T { - return JSON.parse(JSON.stringify(obj)) as T; -} - -export class MemoryDatastoreProvider implements DatastoreProvider { - private collections = new Map>(); - - getCollection( - name: string, - _partitionKeyPath: string - ): DocumentCollection { - let collection = this.collections.get(name); - if (!collection) { - collection = new MemoryCollection(); - this.collections.set(name, collection); - } - return collection as unknown as DocumentCollection; - } - - async isHealthy(): Promise { - return true; - } - - /** Clear all collections (for test cleanup). */ - clear(): void { - this.collections.clear(); - } -} - -class MemoryCollection implements DocumentCollection { - private docs = new Map(); - - async findById(id: string, _partitionKey: string): Promise { - return this.docs.get(id) ?? null; - } - - async findMany(query?: CollectionQuery): Promise { - let results = [...this.docs.values()]; - - if (query?.filter) { - results = results.filter(doc => matchesFilter(doc as Record, query.filter!)); - } - - if (query?.sort) { - const sortEntries = Object.entries(query.sort) as Array<[string, 1 | -1]>; - results.sort((a, b) => { - for (const [field, dir] of sortEntries) { - const aVal = (a as Record)[field]; - const bVal = (b as Record)[field]; - const cmp = compareValues(aVal, bVal); - if (cmp !== 0) return cmp * dir; - } - return 0; - }); - } - - if (query?.offset) { - results = results.slice(query.offset); - } - - if (query?.limit) { - results = results.slice(0, query.limit); - } - - if (query?.select && query.select.length > 0) { - results = results.map(doc => { - const picked: Record = {}; - for (const key of query.select!) { - picked[key as string] = (doc as Record)[key as string]; - } - return picked as T; - }); - } - - return results; - } - - async findOne(query?: CollectionQuery): Promise { - const results = await this.findMany({ ...query, limit: 1 }); - return results[0] ?? null; - } - - async count(filter?: FilterMap): Promise { - if (!filter) return this.docs.size; - const results = [...this.docs.values()].filter(doc => - matchesFilter(doc as Record, filter) - ); - return results.length; - } - - async create(doc: T): Promise { - if (this.docs.has(doc.id)) { - throw new Error(`Document with id '${doc.id}' already exists`); - } - const clone = deepClone(doc); - this.docs.set(doc.id, clone); - return clone; - } - - async update(id: string, _partitionKey: string, updates: Partial): Promise { - const existing = this.docs.get(id); - if (!existing) { - throw new Error(`Document with id '${id}' not found`); - } - const merged = { ...existing, ...updates } as T; - this.docs.set(id, deepClone(merged)); - return deepClone(merged); - } - - async upsert(doc: T): Promise { - const clone = deepClone(doc); - this.docs.set(doc.id, clone); - return clone; - } - - async delete(id: string, _partitionKey: string): Promise { - this.docs.delete(id); - } - - async aggregate>(query: AggregateQuery): Promise { - let docs = [...this.docs.values()]; - if (query.filter) { - docs = docs.filter(doc => matchesFilter(doc as Record, query.filter!)); - } - - // Group by field - const groups = new Map(); - for (const doc of docs) { - const key = String((doc as Record)[query.groupBy] ?? '__null__'); - const group = groups.get(key) ?? []; - group.push(doc); - groups.set(key, group); - } - - const results: Record[] = []; - for (const [groupKey, groupDocs] of groups) { - const row: Record = { [query.groupBy]: groupKey }; - for (const agg of query.aggregations) { - row[agg.alias] = computeAggregation(groupDocs, agg.field, agg.op); - } - results.push(row); - } - - return results as R[]; - } - - async rawQuery(_query: string, _parameters?: Record): Promise { - throw new Error( - 'rawQuery is not supported by MemoryDatastoreProvider. Use findMany/aggregate instead.' - ); - } -} - -function compareValues(a: unknown, b: unknown): number { - if (a === b) return 0; - if (a == null) return -1; - if (b == null) return 1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); - return String(a).localeCompare(String(b)); -} - -function computeAggregation( - docs: T[], - field: string, - op: 'count' | 'sum' | 'avg' | 'min' | 'max' -): number { - if (op === 'count') return docs.length; - - const values = docs - .map(d => (d as Record)[field]) - .filter((v): v is number => typeof v === 'number'); - - if (values.length === 0) return 0; - - switch (op) { - case 'sum': - return values.reduce((a, b) => a + b, 0); - case 'avg': - return values.reduce((a, b) => a + b, 0) / values.length; - case 'min': - return Math.min(...values); - case 'max': - return Math.max(...values); - } -} diff --git a/vendor/bytelyst/datastore/src/testing.ts b/vendor/bytelyst/datastore/src/testing.ts deleted file mode 100644 index 6466bbf..0000000 --- a/vendor/bytelyst/datastore/src/testing.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Test helpers for @bytelyst/datastore. - * - * Use setTestProvider('memory') in beforeAll() to wire up - * a fast, deterministic in-memory provider for tests. - */ - -import { setDatastore, _resetDatastore } from './factory.js'; -import { MemoryDatastoreProvider } from './providers/memory.js'; -import type { BaseDocument, DocumentCollection, DatastoreProviderType } from './types.js'; - -let _testProvider: MemoryDatastoreProvider | null = null; - -/** - * Set a test provider. Call in beforeAll(). - * Currently only 'memory' is supported for testing. - */ -export function setTestProvider(type: DatastoreProviderType = 'memory'): MemoryDatastoreProvider { - if (type !== 'memory') { - throw new Error(`setTestProvider only supports 'memory', got '${type}'`); - } - _testProvider = new MemoryDatastoreProvider(); - setDatastore(_testProvider); - return _testProvider; -} - -/** - * Clear all test data. Call in afterEach() or afterAll(). - */ -export function clearTestData(): void { - _testProvider?.clear(); -} - -/** - * Reset the test provider. Call in afterAll(). - */ -export function resetTestProvider(): void { - _testProvider?.clear(); - _testProvider = null; - _resetDatastore(); -} - -/** - * Seed a collection with documents for testing. - */ -export async function seedCollection( - collection: DocumentCollection, - docs: T[] -): Promise { - for (const doc of docs) { - await collection.create(doc); - } -} diff --git a/vendor/bytelyst/datastore/src/types.ts b/vendor/bytelyst/datastore/src/types.ts deleted file mode 100644 index 02fd04e..0000000 --- a/vendor/bytelyst/datastore/src/types.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Cloud-agnostic datastore interfaces. - * - * Provides DocumentCollection and DatastoreProvider abstractions - * that work with Cosmos DB, MongoDB, or in-memory storage. - */ - -// ── Base document type ────────────────────────────────────────────────────── - -export interface BaseDocument { - id: string; - productId: string; -} - -// ── Filter operators ──────────────────────────────────────────────────────── - -export type FilterValue = string | number | boolean | null; - -export interface FilterOperator { - $gt?: FilterValue; - $gte?: FilterValue; - $lt?: FilterValue; - $lte?: FilterValue; - $ne?: FilterValue; - $exists?: boolean; - $startsWith?: string; - $contains?: FilterValue; - $in?: FilterValue[]; -} - -export type FilterCondition = FilterValue | FilterOperator; - -export interface FilterMap { - [field: string]: FilterCondition | FilterMap[] | undefined; - $or?: FilterMap[]; -} - -// ── Query types ───────────────────────────────────────────────────────────── - -export interface CollectionQuery { - filter?: FilterMap; - sort?: Partial>; - limit?: number; - offset?: number; - select?: (keyof T & string)[]; -} - -export interface AggregateQuery { - groupBy: string; - aggregations: AggregationField[]; - filter?: FilterMap; -} - -export interface AggregationField { - field: string; - op: 'count' | 'sum' | 'avg' | 'min' | 'max'; - alias: string; -} - -// ── Document collection interface ─────────────────────────────────────────── - -export interface DocumentCollection { - /** Find a single document by ID + partition key. */ - findById(id: string, partitionKey: string): Promise; - - /** Find multiple documents matching a query. */ - findMany(query?: CollectionQuery): Promise; - - /** Find the first document matching a query. */ - findOne(query?: CollectionQuery): Promise; - - /** Count documents matching a filter. */ - count(filter?: FilterMap): Promise; - - /** Create a new document. */ - create(doc: T): Promise; - - /** Update a document by ID + partition key (merge semantics). */ - update(id: string, partitionKey: string, updates: Partial): Promise; - - /** Upsert a document (create or replace). */ - upsert(doc: T): Promise; - - /** Delete a document by ID + partition key. */ - delete(id: string, partitionKey: string): Promise; - - /** Run an aggregation query. */ - aggregate>(query: AggregateQuery): Promise; - - /** - * Execute a raw provider-specific query (escape hatch). - * Returns results as-is from the underlying provider. - */ - rawQuery(query: string, parameters?: Record): Promise; -} - -// ── Datastore provider interface ──────────────────────────────────────────── - -export interface DatastoreProvider { - /** Get a collection by name with a partition key path. */ - getCollection( - name: string, - partitionKeyPath: string - ): DocumentCollection; - - /** Check if the datastore is healthy / reachable. */ - isHealthy(): Promise; -} - -// ── Provider config ───────────────────────────────────────────────────────── - -export type DatastoreProviderType = 'cosmos' | 'memory'; diff --git a/vendor/bytelyst/datastore/tsconfig.json b/vendor/bytelyst/datastore/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/datastore/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/design-tokens/README.md b/vendor/bytelyst/design-tokens/README.md deleted file mode 100644 index 3e33d52..0000000 --- a/vendor/bytelyst/design-tokens/README.md +++ /dev/null @@ -1,276 +0,0 @@ -# @bytelyst/design-tokens - -ByteLyst cross-platform design system tokens. Single source of truth for colors, typography, spacing, and more across Web, iOS, Android/KMP, and React Native. - -## Quick Start - -### Install - -```bash -# This is a workspace package - use from source -# Copy generated files to your project -``` - -### Generate Tokens - -```bash -# From the monorepo root -pnpm --filter @bytelyst/design-tokens generate -``` - -This regenerates all platform outputs from `tokens/bytelyst.tokens.json`. - ---- - -## Token Structure - -### Color Tokens - -| Token Path | Example Value | Usage | -| ----------------------------------- | ------------- | -------------------------- | -| `color.semantic.dark.bgCanvas` | `#06070A` | Dark mode background | -| `color.semantic.dark.accentPrimary` | `#5A8CFF` | Primary brand color | -| `color.semantic.light.surfaceCard` | `#FFFFFF` | Light mode card background | -| `color.nomgap.stageKetosis` | `#5A8CFF` | Product-specific color | - -### Typography Tokens - -| Token Path | Value | CSS Output | -| -------------------------------- | ----------------- | ------------------- | -| `typography.fontFamily.display` | `'Space Grotesk'` | `--ml-font-display` | -| `typography.fontSize.lg` | `18` | `--ml-fs-lg: 18px` | -| `typography.fontWeight.semibold` | `600` | — | - -### Spacing Tokens (8pt Grid) - -| Token | Value | CSS | -| ----------- | ----- | -------------------- | -| `spacing.1` | `4` | `--ml-space-1: 4px` | -| `spacing.4` | `16` | `--ml-space-4: 16px` | -| `spacing.8` | `32` | `--ml-space-8: 32px` | - -### Elevation Tokens - -| Token | CSS Output | -| -------------- | ------------------------------------------------- | -| `elevation.sm` | `--ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12)` | -| `elevation.md` | `--ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18)` | - ---- - -## Platform Usage - -### Web (CSS) - -```css -/* Import the CSS file */ -@import '@bytelyst/design-tokens/generated/tokens.css'; - -/* Use tokens */ -.my-component { - background-color: var(--ml-bg-canvas); - color: var(--ml-text-primary); - padding: var(--ml-space-4); - border-radius: var(--ml-radius-md); - box-shadow: var(--ml-elevation-sm); - font-family: var(--ml-font-display); - font-size: var(--ml-fs-lg); -} -``` - -#### With Tailwind CSS - -```html - -
- Content -
-``` - -### Web (TypeScript) - -```typescript -import { tokens } from '@bytelyst/design-tokens/generated/tokens'; - -// Access any token programmatically -const primaryColor = tokens.color.semantic.dark.accentPrimary; -const fontSize = tokens.typography.fontSize.lg; -``` - -### iOS (SwiftUI) - -```swift -import SwiftUI - -// Copy MindLystTheme.swift to your project -// Rename to YourProductTheme.swift - -struct MyView: View { - var body: some View { - Text("Hello") - .foregroundColor(ProductColors.darkTextPrimary) - .background(ProductColors.darkBgCanvas) - .font(.system(size: ProductSpacing.x4)) - } -} -``` - -### Android/KMP (Kotlin + Compose) - -```kotlin -import com.mindlyst.shared.theme.MindLystTokens - -@Composable -fun MyComponent() { - Box( - modifier = Modifier - .background(Color(MindLystTokens.Dark.BG_CANVAS)) - .padding(MindLystTokens.Spacing.X4.dp) - ) { - Text( - text = "Hello", - color = Color(MindLystTokens.Dark.TEXT_PRIMARY), - fontSize = MindLystTokens.Typography.SIZE_LG.sp - ) - } -} -``` - -### React Native (Expo) - -```typescript -import { tokens } from '@bytelyst/design-tokens/generated/react-native/tokens'; -import { StyleSheet } from 'react-native'; - -const styles = StyleSheet.create({ - container: { - backgroundColor: tokens.colors.bgCanvas, - padding: tokens.spacing['4'], - borderRadius: tokens.radius.md, - }, - text: { - color: tokens.colors.textPrimary, - fontSize: tokens.typography.fontSize.lg, - }, -}); -``` - ---- - -## Product-Specific Tokens - -Each product has dedicated color tokens: - -```json -{ - "color": { - "nomgap": { - "stageFed": "#FF9F43", - "stageKetosis": "#5A8CFF", - "autophagyMeter": "#5AE68C" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "focusMode": "#7C6BFF" - }, - "peakpulse": { - "activityHike": "#34D399", - "speedZoneFast": "#FFD166" - } - } -} -``` - ---- - -## Validation & Compliance - -### Check for Hardcoded Colors - -```bash -# From your product repo -node ../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ -``` - -This scans for `#RRGGBB`, `rgb()`, `rgba()`, and `hsl()` patterns. - -### Get Token Coverage Report - -```bash -# From your product repo -node ../learning_ai_common_plat/packages/design-tokens/scripts/token-coverage.cjs src/ -``` - -Reports: - -- Percentage of files using tokens -- Number of hardcoded colors found -- Token adoption rate - ---- - -## CI Integration - -### GitHub Actions Example - -```yaml -- name: Check for hardcoded colors - run: | - node ../../learning_ai_common_plat/packages/design-tokens/scripts/validate-tokens.cjs src/ - if [ $? -ne 0 ]; then - echo "❌ Hardcoded colors found. Use design tokens instead." - exit 1 - fi -``` - -### Pre-commit Hook - -```bash -# .husky/pre-commit or .git/hooks/pre-commit -node packages/design-tokens/scripts/validate-tokens.cjs src/ || true -``` - ---- - -## Token Categories (v1.1.0) - -| Category | Count | Description | -| -------------- | ----- | ------------------------------------------------ | -| **Color** | 80+ | Palette, semantic (dark/light), product-specific | -| **Typography** | 20+ | Font families, sizes, weights, line heights | -| **Spacing** | 13 | 8pt grid (0-64px) | -| **Radius** | 6 | Corner radii (xs to pill) | -| **Elevation** | 4 | Shadow depths | -| **Motion** | 7 | Durations, easings | -| **Z-Index** | 9 | Layer stacking | -| **Icon** | 6 | Icon sizes | -| **Grid** | 12 | 12-column system | -| **Opacity** | 11 | Alpha values | - ---- - -## Version History - -| Version | Date | Changes | -| ------- | ---------- | ------------------------------------------------------------------------------------------------- | -| 1.1.0 | 2026-03-03 | Added product tokens (PeakPulse, ChronoMind, NomGap, LysnrAI), z-index, icon sizes, grid, opacity | -| 1.0.0 | 2026-02-12 | Initial release with color, typography, spacing, radius, elevation, motion | - ---- - -## Contributing - -1. Edit `tokens/bytelyst.tokens.json` (canonical source) -2. Run `pnpm generate` to regenerate outputs -3. Copy generated files to consumer repos -4. Commit both source and generated files - -**Never edit generated files directly** — they will be overwritten. - ---- - -## See Also - -- [Design System Audit Report](../../docs/design-system/DESIGN_SYSTEM_AUDIT_2026-03-03.md) -- [AGENTS.md](../../AGENTS.md) — Coding agent instructions -- [ECOSYSTEM_ARCHITECTURE.md](../../docs/ECOSYSTEM_ARCHITECTURE.md) diff --git a/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz b/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz deleted file mode 100644 index c526e95..0000000 Binary files a/vendor/bytelyst/design-tokens/bytelyst-design-tokens-0.1.0.tgz and /dev/null differ diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift b/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift deleted file mode 100644 index 0209ca7..0000000 --- a/vendor/bytelyst/design-tokens/generated/MindLystTheme.swift +++ /dev/null @@ -1,88 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually -import SwiftUI - -// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens) -// These values mirror MindLystTokens.kt exactly. - -struct MindLystColors { - // Dark - static let darkBgCanvas = Color(hex: 0x06070A) - static let darkBgElevated = Color(hex: 0x0E1118) - static let darkSurfaceCard = Color(hex: 0x121725) - static let darkSurfaceMuted = Color(hex: 0x1A2335) - static let darkBorder = Color.white.opacity(0.12) - static let darkTextPrimary = Color(hex: 0xEFF4FF) - static let darkTextSecondary = Color(hex: 0xA5B1C7) - static let darkTextTertiary = Color(hex: 0x6C7C98) - static let darkAccentPrimary = Color(hex: 0x5A8CFF) - static let darkAccentSecondary = Color(hex: 0x2EE6D6) - static let darkSuccess = Color(hex: 0x34D399) - static let darkWarning = Color(hex: 0xF59E0B) - static let darkDanger = Color(hex: 0xFF6E6E) - - // Light - static let lightBgCanvas = Color(hex: 0xF6F8FC) - static let lightBgElevated = Color(hex: 0xEEF2FA) - static let lightSurfaceCard = Color.white - static let lightSurfaceMuted = Color(hex: 0xF3F5FA) - static let lightTextPrimary = Color(hex: 0x0E1320) - static let lightTextSecondary = Color(hex: 0x55637A) - static let lightTextTertiary = Color(hex: 0x6C7C98) - static let lightAccentPrimary = Color(hex: 0x5A8CFF) - static let lightAccentSecondary = Color(hex: 0x2EE6D6) - static let lightSuccess = Color(hex: 0x13956A) - static let lightWarning = Color(hex: 0xB87504) - static let lightDanger = Color(hex: 0xD24242) - - // Brain Gradients - static let brainWork = Gradient(colors: [Color(hex: 0x5A8CFF), Color(hex: 0x2EE6D6)]) - static let brainHome = Gradient(colors: [Color(hex: 0xFF6E6E), Color(hex: 0xFFD166)]) - static let brainMoney = Gradient(colors: [Color(hex: 0x34D399), Color(hex: 0x2EE6D6)]) - static let brainHealth = Gradient(colors: [Color(hex: 0x2EE6D6), Color(hex: 0x9FE870)]) - static let brainGlobal = Gradient(colors: [Color(hex: 0x7D8FB4), Color(hex: 0xA5B1C7)]) -} - -struct MindLystSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -struct MindLystRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -struct MindLystMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt b/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt deleted file mode 100644 index 20c4faf..0000000 --- a/vendor/bytelyst/design-tokens/generated/MindLystTokens.kt +++ /dev/null @@ -1,137 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually -package com.mindlyst.shared.theme - -/** - * Cross-platform design tokens from bytelyst.tokens.json. - * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI). - */ -object MindLystTokens { - - // ── Color Palette ──────────────────────────────────────────────── - object Palette { - const val NEUTRAL_0 = 0xFFFFFFFF - const val NEUTRAL_50 = 0xFFF6F8FC - const val NEUTRAL_100 = 0xFFEEF2FA - const val NEUTRAL_200 = 0xFFDCE4F2 - const val NEUTRAL_300 = 0xFFBFCBDE - const val NEUTRAL_400 = 0xFF92A1BA - const val NEUTRAL_500 = 0xFF6C7C98 - const val NEUTRAL_600 = 0xFF55637A - const val NEUTRAL_700 = 0xFF3B455A - const val NEUTRAL_800 = 0xFF1A2335 - const val NEUTRAL_900 = 0xFF0E1320 - const val NEUTRAL_950 = 0xFF06070A - - const val BLUE = 0xFF5A8CFF - const val CYAN = 0xFF2EE6D6 - const val CORAL = 0xFFFF6E6E - const val GOLD = 0xFFFFD166 - const val MINT = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val MICROSOFTRED = 0xFFF25022 - const val MICROSOFTGREEN = 0xFF7FBA00 - const val MICROSOFTBLUE = 0xFF00A4EF - const val MICROSOFTYELLOW = 0xFFFFB900 - const val GOOGLEBLUE = 0xFF4285F4 - const val GOOGLEGREEN = 0xFF34A853 - const val GOOGLEYELLOW = 0xFFFBBC05 - const val GOOGLERED = 0xFFEA4335 - } - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Brain Identity Gradients ───────────────────────────────────── - data class BrainGradient(val from: Long, val to: Long) - - val BRAIN_WORK = BrainGradient(from = 0xFF5A8CFF, to = 0xFF2EE6D6) - val BRAIN_HOME = BrainGradient(from = 0xFFFF6E6E, to = 0xFFFFD166) - val BRAIN_MONEY = BrainGradient(from = 0xFF34D399, to = 0xFF2EE6D6) - val BRAIN_HEALTH = BrainGradient(from = 0xFF2EE6D6, to = 0xFF9FE870) - val BRAIN_GLOBAL = BrainGradient(from = 0xFF7D8FB4, to = 0xFFA5B1C7) - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - - const val SIZE_XS = 12 - const val SIZE_SM = 14 - const val SIZE_MD = 16 - const val SIZE_LG = 18 - const val SIZE_XL = 22 - const val SIZE_2XL = 28 - const val SIZE_3XL = 36 - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } - - // ── Layout ─────────────────────────────────────────────────────── - object Layout { - const val TOUCH_TARGET_MIN = 44 - const val MOBILE_GUTTER = 16 - const val MAX_WIDTH = 1280 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/actiontrail.css b/vendor/bytelyst/design-tokens/generated/actiontrail.css deleted file mode 100644 index 521beb5..0000000 --- a/vendor/bytelyst/design-tokens/generated/actiontrail.css +++ /dev/null @@ -1,97 +0,0 @@ -/* Auto-generated actiontrail tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --at-bg-canvas: #06070A; - --at-bg-elevated: #0E1118; - --at-surface-card: #121725; - --at-surface-muted: #1A2335; - --at-border-default: rgba(255,255,255,0.12); - --at-border-strong: rgba(255,255,255,0.22); - --at-text-primary: #EFF4FF; - --at-text-secondary: #A5B1C7; - --at-text-tertiary: #6C7C98; - --at-accent-primary: #5A8CFF; - --at-accent-secondary: #2EE6D6; - --at-success: #34D399; - --at-warning: #F59E0B; - --at-danger: #FF6E6E; - --at-focus-ring: rgba(90,140,255,0.45); - --at-overlay-scrim: rgba(5,8,18,0.72); - - /* actiontrail product colors */ - --at-bg: #07111F; - --at-surface: #0F1B2D; - --at-surface-elevated: #152338; - --at-border: #24344D; - --at-text: #EFF4FF; - --at-text-muted: #A8B4C8; - --at-primary: #5A8CFF; - --at-accent: #5AE68C; - --at-warning: #F59E0B; - --at-danger: #FF6E6E; - --at-risk-low: #5AE68C; - --at-risk-medium: #F59E0B; - --at-risk-high: #FF8C42; - --at-risk-critical: #FF6E6E; - --at-status-pending: #F59E0B; - --at-status-applied: #5AE68C; - --at-status-rejected: #FF6E6E; - --at-status-reverted: #A66BFF; - - --at-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --at-font-body: "DM Sans", "SF Pro Text", sans-serif; - --at-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --at-fs-xs: 12px; - --at-fs-sm: 14px; - --at-fs-md: 16px; - --at-fs-lg: 18px; - --at-fs-xl: 22px; - --at-fs-2xl: 28px; - --at-fs-3xl: 36px; - - --at-space-0: 0; - --at-space-1: 4px; - --at-space-2: 8px; - --at-space-3: 12px; - --at-space-4: 16px; - --at-space-5: 20px; - --at-space-6: 24px; - --at-space-7: 28px; - --at-space-8: 32px; - --at-space-10: 40px; - --at-space-12: 48px; - --at-space-16: 64px; - - --at-radius-xs: 8px; - --at-radius-sm: 12px; - --at-radius-md: 16px; - --at-radius-lg: 20px; - --at-radius-xl: 24px; - --at-radius-pill: 999px; - - --at-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --at-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --at-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --at-motion-fast: 140ms; - --at-motion-base: 220ms; - --at-motion-slow: 320ms; - --at-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --at-bg-canvas: #F6F8FC; - --at-bg-elevated: #EEF2FA; - --at-surface-card: #FFFFFF; - --at-surface-muted: #F3F5FA; - --at-border-default: rgba(14,19,32,0.12); - --at-border-strong: rgba(14,19,32,0.24); - --at-text-primary: #0E1320; - --at-text-secondary: #55637A; - --at-success: #13956A; - --at-warning: #B87504; - --at-danger: #D24242; - --at-focus-ring: rgba(90,140,255,0.35); - --at-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/chronomind.css b/vendor/bytelyst/design-tokens/generated/chronomind.css deleted file mode 100644 index 8e9d642..0000000 --- a/vendor/bytelyst/design-tokens/generated/chronomind.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated chronomind tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --cm-bg-canvas: #06070A; - --cm-bg-elevated: #0E1118; - --cm-surface-card: #121725; - --cm-surface-muted: #1A2335; - --cm-border-default: rgba(255,255,255,0.12); - --cm-border-strong: rgba(255,255,255,0.22); - --cm-text-primary: #EFF4FF; - --cm-text-secondary: #A5B1C7; - --cm-text-tertiary: #6C7C98; - --cm-accent-primary: #5A8CFF; - --cm-accent-secondary: #2EE6D6; - --cm-success: #34D399; - --cm-warning: #F59E0B; - --cm-danger: #FF6E6E; - --cm-focus-ring: rgba(90,140,255,0.45); - --cm-overlay-scrim: rgba(5,8,18,0.72); - - /* chronomind product colors */ - --cm-urgency-critical: #FF6E6E; - --cm-urgency-important: #FFD166; - --cm-urgency-standard: #5A8CFF; - --cm-urgency-gentle: #34D399; - --cm-urgency-passive: #A5B1C7; - --cm-focus-mode: #7C6BFF; - --cm-pomodoro-work: #34D399; - --cm-pomodoro-break: #5A8CFF; - --cm-cascade-warning: #FF9F43; - --cm-timer-complete: #34D399; - - --cm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --cm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --cm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --cm-fs-xs: 12px; - --cm-fs-sm: 14px; - --cm-fs-md: 16px; - --cm-fs-lg: 18px; - --cm-fs-xl: 22px; - --cm-fs-2xl: 28px; - --cm-fs-3xl: 36px; - - --cm-space-0: 0; - --cm-space-1: 4px; - --cm-space-2: 8px; - --cm-space-3: 12px; - --cm-space-4: 16px; - --cm-space-5: 20px; - --cm-space-6: 24px; - --cm-space-7: 28px; - --cm-space-8: 32px; - --cm-space-10: 40px; - --cm-space-12: 48px; - --cm-space-16: 64px; - - --cm-radius-xs: 8px; - --cm-radius-sm: 12px; - --cm-radius-md: 16px; - --cm-radius-lg: 20px; - --cm-radius-xl: 24px; - --cm-radius-pill: 999px; - - --cm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --cm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --cm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --cm-motion-fast: 140ms; - --cm-motion-base: 220ms; - --cm-motion-slow: 320ms; - --cm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --cm-bg-canvas: #F6F8FC; - --cm-bg-elevated: #EEF2FA; - --cm-surface-card: #FFFFFF; - --cm-surface-muted: #F3F5FA; - --cm-border-default: rgba(14,19,32,0.12); - --cm-border-strong: rgba(14,19,32,0.24); - --cm-text-primary: #0E1320; - --cm-text-secondary: #55637A; - --cm-success: #13956A; - --cm-warning: #B87504; - --cm-danger: #D24242; - --cm-focus-ring: rgba(90,140,255,0.35); - --cm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/flowmonk.css b/vendor/bytelyst/design-tokens/generated/flowmonk.css deleted file mode 100644 index 16e264f..0000000 --- a/vendor/bytelyst/design-tokens/generated/flowmonk.css +++ /dev/null @@ -1,99 +0,0 @@ -/* Auto-generated flowmonk tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --fm-bg-canvas: #06070A; - --fm-bg-elevated: #0E1118; - --fm-surface-card: #121725; - --fm-surface-muted: #1A2335; - --fm-border-default: rgba(255,255,255,0.12); - --fm-border-strong: rgba(255,255,255,0.22); - --fm-text-primary: #EFF4FF; - --fm-text-secondary: #A5B1C7; - --fm-text-tertiary: #6C7C98; - --fm-accent-primary: #5A8CFF; - --fm-accent-secondary: #2EE6D6; - --fm-success: #34D399; - --fm-warning: #F59E0B; - --fm-danger: #FF6E6E; - --fm-focus-ring: rgba(90,140,255,0.45); - --fm-overlay-scrim: rgba(5,8,18,0.72); - - /* flowmonk product colors */ - --fm-bg: #07111F; - --fm-surface: #0F1B2D; - --fm-surface-elevated: #152338; - --fm-border: #24344D; - --fm-text: #EFF4FF; - --fm-text-muted: #A8B4C8; - --fm-primary: #5A8CFF; - --fm-accent: #5AE68C; - --fm-warning: #F59E0B; - --fm-zonework: #5A8CFF; - --fm-zone-personal: #5AE68C; - --fm-zone-health: #FF6B6B; - --fm-zone-admin: #FECA57; - --fm-zone-learning: #A66BFF; - --fm-urgent-badge: #FF6E6E; - --fm-schedule-entry: #5A8CFF; - --fm-overflow-warning: #F59E0B; - --fm-recommendation-info: #5A8CFF; - --fm-recommendation-warning: #F59E0B; - --fm-recommendation-critical: #FF6E6E; - - --fm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --fm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --fm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --fm-fs-xs: 12px; - --fm-fs-sm: 14px; - --fm-fs-md: 16px; - --fm-fs-lg: 18px; - --fm-fs-xl: 22px; - --fm-fs-2xl: 28px; - --fm-fs-3xl: 36px; - - --fm-space-0: 0; - --fm-space-1: 4px; - --fm-space-2: 8px; - --fm-space-3: 12px; - --fm-space-4: 16px; - --fm-space-5: 20px; - --fm-space-6: 24px; - --fm-space-7: 28px; - --fm-space-8: 32px; - --fm-space-10: 40px; - --fm-space-12: 48px; - --fm-space-16: 64px; - - --fm-radius-xs: 8px; - --fm-radius-sm: 12px; - --fm-radius-md: 16px; - --fm-radius-lg: 20px; - --fm-radius-xl: 24px; - --fm-radius-pill: 999px; - - --fm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --fm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --fm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --fm-motion-fast: 140ms; - --fm-motion-base: 220ms; - --fm-motion-slow: 320ms; - --fm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --fm-bg-canvas: #F6F8FC; - --fm-bg-elevated: #EEF2FA; - --fm-surface-card: #FFFFFF; - --fm-surface-muted: #F3F5FA; - --fm-border-default: rgba(14,19,32,0.12); - --fm-border-strong: rgba(14,19,32,0.24); - --fm-text-primary: #0E1320; - --fm-text-secondary: #55637A; - --fm-success: #13956A; - --fm-warning: #B87504; - --fm-danger: #D24242; - --fm-focus-ring: rgba(90,140,255,0.35); - --fm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/jarvisjr.css b/vendor/bytelyst/design-tokens/generated/jarvisjr.css deleted file mode 100644 index 7fa5832..0000000 --- a/vendor/bytelyst/design-tokens/generated/jarvisjr.css +++ /dev/null @@ -1,88 +0,0 @@ -/* Auto-generated jarvisjr tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --jj-bg-canvas: #06070A; - --jj-bg-elevated: #0E1118; - --jj-surface-card: #121725; - --jj-surface-muted: #1A2335; - --jj-border-default: rgba(255,255,255,0.12); - --jj-border-strong: rgba(255,255,255,0.22); - --jj-text-primary: #EFF4FF; - --jj-text-secondary: #A5B1C7; - --jj-text-tertiary: #6C7C98; - --jj-accent-primary: #5A8CFF; - --jj-accent-secondary: #2EE6D6; - --jj-success: #34D399; - --jj-warning: #F59E0B; - --jj-danger: #FF6E6E; - --jj-focus-ring: rgba(90,140,255,0.45); - --jj-overlay-scrim: rgba(5,8,18,0.72); - - /* jarvisjr product colors */ - --jj-accent-primary: #7C6BFF; - --jj-accent-secondary: #5AE6C8; - --jj-accent-voice: #FF6B8A; - --jj-agent-coach: #5A8CFF; - --jj-agent-lingua: #FFB74D; - --jj-agent-spark: #E040FB; - --jj-agent-mentor: #34D399; - --jj-agent-mirror: #2EE6D6; - --jj-agent-orator: #FF9F43; - - --jj-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --jj-font-body: "DM Sans", "SF Pro Text", sans-serif; - --jj-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --jj-fs-xs: 12px; - --jj-fs-sm: 14px; - --jj-fs-md: 16px; - --jj-fs-lg: 18px; - --jj-fs-xl: 22px; - --jj-fs-2xl: 28px; - --jj-fs-3xl: 36px; - - --jj-space-0: 0; - --jj-space-1: 4px; - --jj-space-2: 8px; - --jj-space-3: 12px; - --jj-space-4: 16px; - --jj-space-5: 20px; - --jj-space-6: 24px; - --jj-space-7: 28px; - --jj-space-8: 32px; - --jj-space-10: 40px; - --jj-space-12: 48px; - --jj-space-16: 64px; - - --jj-radius-xs: 8px; - --jj-radius-sm: 12px; - --jj-radius-md: 16px; - --jj-radius-lg: 20px; - --jj-radius-xl: 24px; - --jj-radius-pill: 999px; - - --jj-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --jj-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --jj-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --jj-motion-fast: 140ms; - --jj-motion-base: 220ms; - --jj-motion-slow: 320ms; - --jj-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --jj-bg-canvas: #F6F8FC; - --jj-bg-elevated: #EEF2FA; - --jj-surface-card: #FFFFFF; - --jj-surface-muted: #F3F5FA; - --jj-border-default: rgba(14,19,32,0.12); - --jj-border-strong: rgba(14,19,32,0.24); - --jj-text-primary: #0E1320; - --jj-text-secondary: #55637A; - --jj-success: #13956A; - --jj-warning: #B87504; - --jj-danger: #D24242; - --jj-focus-ring: rgba(90,140,255,0.35); - --jj-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/localllmlab.css b/vendor/bytelyst/design-tokens/generated/localllmlab.css deleted file mode 100644 index 2a62100..0000000 --- a/vendor/bytelyst/design-tokens/generated/localllmlab.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Auto-generated localllmlab tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --llm-bg-canvas: #06070A; - --llm-bg-elevated: #0E1118; - --llm-surface-card: #121725; - --llm-surface-muted: #1A2335; - --llm-border-default: rgba(255,255,255,0.12); - --llm-border-strong: rgba(255,255,255,0.22); - --llm-text-primary: #EFF4FF; - --llm-text-secondary: #A5B1C7; - --llm-text-tertiary: #6C7C98; - --llm-accent-primary: #5A8CFF; - --llm-accent-secondary: #2EE6D6; - --llm-success: #34D399; - --llm-warning: #F59E0B; - --llm-danger: #FF6E6E; - --llm-focus-ring: rgba(90,140,255,0.45); - --llm-overlay-scrim: rgba(5,8,18,0.72); - - /* localllmlab product colors */ - --llm-bg-canvas: #06070A; - --llm-bg-elevated: #0E1118; - --llm-surface-card: #121725; - --llm-surface-muted: #1A2335; - --llm-border-subtle: #1E293B; - --llm-border-default: #2A3654; - --llm-text-primary: #EFF4FF; - --llm-text-secondary: #A5B1C7; - --llm-text-tertiary: #6C7C98; - --llm-accent-primary: #5A8CFF; - --llm-accent-secondary: #2EE6D6; - --llm-success: #34D399; - --llm-warning: #F59E0B; - --llm-danger: #FF6E6E; - --llm-purple: #A78BFA; - - --llm-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --llm-font-body: "DM Sans", "SF Pro Text", sans-serif; - --llm-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --llm-fs-xs: 12px; - --llm-fs-sm: 14px; - --llm-fs-md: 16px; - --llm-fs-lg: 18px; - --llm-fs-xl: 22px; - --llm-fs-2xl: 28px; - --llm-fs-3xl: 36px; - - --llm-space-0: 0; - --llm-space-1: 4px; - --llm-space-2: 8px; - --llm-space-3: 12px; - --llm-space-4: 16px; - --llm-space-5: 20px; - --llm-space-6: 24px; - --llm-space-7: 28px; - --llm-space-8: 32px; - --llm-space-10: 40px; - --llm-space-12: 48px; - --llm-space-16: 64px; - - --llm-radius-xs: 8px; - --llm-radius-sm: 12px; - --llm-radius-md: 16px; - --llm-radius-lg: 20px; - --llm-radius-xl: 24px; - --llm-radius-pill: 999px; - - --llm-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --llm-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --llm-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --llm-motion-fast: 140ms; - --llm-motion-base: 220ms; - --llm-motion-slow: 320ms; - --llm-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --llm-bg-canvas: #F6F8FC; - --llm-bg-elevated: #EEF2FA; - --llm-surface-card: #FFFFFF; - --llm-surface-muted: #F3F5FA; - --llm-border-default: rgba(14,19,32,0.12); - --llm-border-strong: rgba(14,19,32,0.24); - --llm-text-primary: #0E1320; - --llm-text-secondary: #55637A; - --llm-success: #13956A; - --llm-warning: #B87504; - --llm-danger: #D24242; - --llm-focus-ring: rgba(90,140,255,0.35); - --llm-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/localmemgpt.css b/vendor/bytelyst/design-tokens/generated/localmemgpt.css deleted file mode 100644 index 05beae5..0000000 --- a/vendor/bytelyst/design-tokens/generated/localmemgpt.css +++ /dev/null @@ -1,93 +0,0 @@ -/* Auto-generated localmemgpt tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --lmg-bg-canvas: #06070A; - --lmg-bg-elevated: #0E1118; - --lmg-surface-card: #121725; - --lmg-surface-muted: #1A2335; - --lmg-border-default: rgba(255,255,255,0.12); - --lmg-border-strong: rgba(255,255,255,0.22); - --lmg-text-primary: #EFF4FF; - --lmg-text-secondary: #A5B1C7; - --lmg-text-tertiary: #6C7C98; - --lmg-accent-primary: #5A8CFF; - --lmg-accent-secondary: #2EE6D6; - --lmg-success: #34D399; - --lmg-warning: #F59E0B; - --lmg-danger: #FF6E6E; - --lmg-focus-ring: rgba(90,140,255,0.45); - --lmg-overlay-scrim: rgba(5,8,18,0.72); - - /* localmemgpt product colors */ - --lmg-bg-primary: #0A0A0A; - --lmg-bg-secondary: #141414; - --lmg-bg-tertiary: #1E1E1E; - --lmg-bg-hover: #252525; - --lmg-bg-input: #1A1A1A; - --lmg-border: #2A2A2A; - --lmg-text-primary: #F0F0F0; - --lmg-text-secondary: #999999; - --lmg-text-muted: #666666; - --lmg-accent: #6366F1; - --lmg-accent-hover: #818CF8; - --lmg-success: #22C55E; - --lmg-warning: #F59E0B; - --lmg-error: #EF4444; - - --lmg-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --lmg-font-body: "DM Sans", "SF Pro Text", sans-serif; - --lmg-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --lmg-fs-xs: 12px; - --lmg-fs-sm: 14px; - --lmg-fs-md: 16px; - --lmg-fs-lg: 18px; - --lmg-fs-xl: 22px; - --lmg-fs-2xl: 28px; - --lmg-fs-3xl: 36px; - - --lmg-space-0: 0; - --lmg-space-1: 4px; - --lmg-space-2: 8px; - --lmg-space-3: 12px; - --lmg-space-4: 16px; - --lmg-space-5: 20px; - --lmg-space-6: 24px; - --lmg-space-7: 28px; - --lmg-space-8: 32px; - --lmg-space-10: 40px; - --lmg-space-12: 48px; - --lmg-space-16: 64px; - - --lmg-radius-xs: 8px; - --lmg-radius-sm: 12px; - --lmg-radius-md: 16px; - --lmg-radius-lg: 20px; - --lmg-radius-xl: 24px; - --lmg-radius-pill: 999px; - - --lmg-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --lmg-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --lmg-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --lmg-motion-fast: 140ms; - --lmg-motion-base: 220ms; - --lmg-motion-slow: 320ms; - --lmg-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --lmg-bg-canvas: #F6F8FC; - --lmg-bg-elevated: #EEF2FA; - --lmg-surface-card: #FFFFFF; - --lmg-surface-muted: #F3F5FA; - --lmg-border-default: rgba(14,19,32,0.12); - --lmg-border-strong: rgba(14,19,32,0.24); - --lmg-text-primary: #0E1320; - --lmg-text-secondary: #55637A; - --lmg-success: #13956A; - --lmg-warning: #B87504; - --lmg-danger: #D24242; - --lmg-focus-ring: rgba(90,140,255,0.35); - --lmg-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/lysnrai.css b/vendor/bytelyst/design-tokens/generated/lysnrai.css deleted file mode 100644 index 383be4a..0000000 --- a/vendor/bytelyst/design-tokens/generated/lysnrai.css +++ /dev/null @@ -1,86 +0,0 @@ -/* Auto-generated lysnrai tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --lys-bg-canvas: #06070A; - --lys-bg-elevated: #0E1118; - --lys-surface-card: #121725; - --lys-surface-muted: #1A2335; - --lys-border-default: rgba(255,255,255,0.12); - --lys-border-strong: rgba(255,255,255,0.22); - --lys-text-primary: #EFF4FF; - --lys-text-secondary: #A5B1C7; - --lys-text-tertiary: #6C7C98; - --lys-accent-primary: #5A8CFF; - --lys-accent-secondary: #2EE6D6; - --lys-success: #34D399; - --lys-warning: #F59E0B; - --lys-danger: #FF6E6E; - --lys-focus-ring: rgba(90,140,255,0.45); - --lys-overlay-scrim: rgba(5,8,18,0.72); - - /* lysnrai product colors */ - --lys-recording-active: #FF6E6E; - --lys-recording-paused: #FFD166; - --lys-processing: #5A8CFF; - --lys-transcribed: #34D399; - --lys-dictation-mode: #7C6BFF; - --lys-command-mode: #2EE6D6; - --lys-hotkey-active: #FF6B8A; - - --lys-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --lys-font-body: "DM Sans", "SF Pro Text", sans-serif; - --lys-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --lys-fs-xs: 12px; - --lys-fs-sm: 14px; - --lys-fs-md: 16px; - --lys-fs-lg: 18px; - --lys-fs-xl: 22px; - --lys-fs-2xl: 28px; - --lys-fs-3xl: 36px; - - --lys-space-0: 0; - --lys-space-1: 4px; - --lys-space-2: 8px; - --lys-space-3: 12px; - --lys-space-4: 16px; - --lys-space-5: 20px; - --lys-space-6: 24px; - --lys-space-7: 28px; - --lys-space-8: 32px; - --lys-space-10: 40px; - --lys-space-12: 48px; - --lys-space-16: 64px; - - --lys-radius-xs: 8px; - --lys-radius-sm: 12px; - --lys-radius-md: 16px; - --lys-radius-lg: 20px; - --lys-radius-xl: 24px; - --lys-radius-pill: 999px; - - --lys-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --lys-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --lys-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --lys-motion-fast: 140ms; - --lys-motion-base: 220ms; - --lys-motion-slow: 320ms; - --lys-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --lys-bg-canvas: #F6F8FC; - --lys-bg-elevated: #EEF2FA; - --lys-surface-card: #FFFFFF; - --lys-surface-muted: #F3F5FA; - --lys-border-default: rgba(14,19,32,0.12); - --lys-border-strong: rgba(14,19,32,0.24); - --lys-text-primary: #0E1320; - --lys-text-secondary: #55637A; - --lys-success: #13956A; - --lys-warning: #B87504; - --lys-danger: #D24242; - --lys-focus-ring: rgba(90,140,255,0.35); - --lys-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/mindlyst.css b/vendor/bytelyst/design-tokens/generated/mindlyst.css deleted file mode 100644 index 327e690..0000000 --- a/vendor/bytelyst/design-tokens/generated/mindlyst.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated mindlyst tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --ml-bg-canvas: #06070A; - --ml-bg-elevated: #0E1118; - --ml-surface-card: #121725; - --ml-surface-muted: #1A2335; - --ml-border-default: rgba(255,255,255,0.12); - --ml-border-strong: rgba(255,255,255,0.22); - --ml-text-primary: #EFF4FF; - --ml-text-secondary: #A5B1C7; - --ml-text-tertiary: #6C7C98; - --ml-accent-primary: #5A8CFF; - --ml-accent-secondary: #2EE6D6; - --ml-success: #34D399; - --ml-warning: #F59E0B; - --ml-danger: #FF6E6E; - --ml-focus-ring: rgba(90,140,255,0.45); - --ml-overlay-scrim: rgba(5,8,18,0.72); - - /* mindlyst product colors */ - --ml-work-from: #5A8CFF; - --ml-work-to: #2EE6D6; - --ml-home-from: #FF6E6E; - --ml-home-to: #FFD166; - --ml-money-from: #34D399; - --ml-money-to: #2EE6D6; - --ml-health-from: #2EE6D6; - --ml-health-to: #9FE870; - --ml-global-from: #7D8FB4; - --ml-global-to: #A5B1C7; - - --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ml-fs-xs: 12px; - --ml-fs-sm: 14px; - --ml-fs-md: 16px; - --ml-fs-lg: 18px; - --ml-fs-xl: 22px; - --ml-fs-2xl: 28px; - --ml-fs-3xl: 36px; - - --ml-space-0: 0; - --ml-space-1: 4px; - --ml-space-2: 8px; - --ml-space-3: 12px; - --ml-space-4: 16px; - --ml-space-5: 20px; - --ml-space-6: 24px; - --ml-space-7: 28px; - --ml-space-8: 32px; - --ml-space-10: 40px; - --ml-space-12: 48px; - --ml-space-16: 64px; - - --ml-radius-xs: 8px; - --ml-radius-sm: 12px; - --ml-radius-md: 16px; - --ml-radius-lg: 20px; - --ml-radius-xl: 24px; - --ml-radius-pill: 999px; - - --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ml-motion-fast: 140ms; - --ml-motion-base: 220ms; - --ml-motion-slow: 320ms; - --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ml-bg-canvas: #F6F8FC; - --ml-bg-elevated: #EEF2FA; - --ml-surface-card: #FFFFFF; - --ml-surface-muted: #F3F5FA; - --ml-border-default: rgba(14,19,32,0.12); - --ml-border-strong: rgba(14,19,32,0.24); - --ml-text-primary: #0E1320; - --ml-text-secondary: #55637A; - --ml-success: #13956A; - --ml-warning: #B87504; - --ml-danger: #D24242; - --ml-focus-ring: rgba(90,140,255,0.35); - --ml-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift deleted file mode 100644 index 2bb33f9..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTheme.generated.swift +++ /dev/null @@ -1,103 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: actiontrail -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum ActionTrailColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Actiontrail Product Colors - static let bg = Color(hex: 0x07111F) - static let surface = Color(hex: 0x0F1B2D) - static let surfaceElevated = Color(hex: 0x152338) - static let border = Color(hex: 0x24344D) - static let text = Color(hex: 0xEFF4FF) - static let textMuted = Color(hex: 0xA8B4C8) - static let primary = Color(hex: 0x5A8CFF) - static let accent = Color(hex: 0x5AE68C) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let riskLow = Color(hex: 0x5AE68C) - static let riskMedium = Color(hex: 0xF59E0B) - static let riskHigh = Color(hex: 0xFF8C42) - static let riskCritical = Color(hex: 0xFF6E6E) - static let statusPending = Color(hex: 0xF59E0B) - static let statusApplied = Color(hex: 0x5AE68C) - static let statusRejected = Color(hex: 0xFF6E6E) - static let statusReverted = Color(hex: 0xA66BFF) - -} - -enum ActionTrailColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum ActionTrailSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum ActionTrailRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum ActionTrailMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt deleted file mode 100644 index eba86c0..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ActionTrailTokens.generated.kt +++ /dev/null @@ -1,102 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: actiontrail -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.actiontrail.theme - -object ActionTrailTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Actiontrail Product Colors ─────────────────────────────── - object Product { - const val BG = 0xFF07111F - const val SURFACE = 0xFF0F1B2D - const val SURFACE_ELEVATED = 0xFF152338 - const val BORDER = 0xFF24344D - const val TEXT = 0xFFEFF4FF - const val TEXT_MUTED = 0xFFA8B4C8 - const val PRIMARY = 0xFF5A8CFF - const val ACCENT = 0xFF5AE68C - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val RISK_LOW = 0xFF5AE68C - const val RISK_MEDIUM = 0xFFF59E0B - const val RISK_HIGH = 0xFFFF8C42 - const val RISK_CRITICAL = 0xFFFF6E6E - const val STATUS_PENDING = 0xFFF59E0B - const val STATUS_APPLIED = 0xFF5AE68C - const val STATUS_REJECTED = 0xFFFF6E6E - const val STATUS_REVERTED = 0xFFA66BFF - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift deleted file mode 100644 index 8b4c085..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: chronomind -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum CMColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Chronomind Product Colors - static let urgencyCritical = Color(hex: 0xFF6E6E) - static let urgencyImportant = Color(hex: 0xFFD166) - static let urgencyStandard = Color(hex: 0x5A8CFF) - static let urgencyGentle = Color(hex: 0x34D399) - static let urgencyPassive = Color(hex: 0xA5B1C7) - static let focusMode = Color(hex: 0x7C6BFF) - static let pomodoroWork = Color(hex: 0x34D399) - static let pomodoroBreak = Color(hex: 0x5A8CFF) - static let cascadeWarning = Color(hex: 0xFF9F43) - static let timerComplete = Color(hex: 0x34D399) - -} - -enum CMColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum CMSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum CMRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum CMMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt deleted file mode 100644 index 09aafe1..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/ChronoMindTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: chronomind -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.chronomind.app.theme - -object ChronoMindTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Chronomind Product Colors ─────────────────────────────── - object Product { - const val URGENCY_CRITICAL = 0xFFFF6E6E - const val URGENCY_IMPORTANT = 0xFFFFD166 - const val URGENCY_STANDARD = 0xFF5A8CFF - const val URGENCY_GENTLE = 0xFF34D399 - const val URGENCY_PASSIVE = 0xFFA5B1C7 - const val FOCUS_MODE = 0xFF7C6BFF - const val POMODORO_WORK = 0xFF34D399 - const val POMODORO_BREAK = 0xFF5A8CFF - const val CASCADE_WARNING = 0xFFFF9F43 - const val TIMER_COMPLETE = 0xFF34D399 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift deleted file mode 100644 index 6701005..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTheme.generated.swift +++ /dev/null @@ -1,105 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: flowmonk -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum FlowMonkColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Flowmonk Product Colors - static let bg = Color(hex: 0x07111F) - static let surface = Color(hex: 0x0F1B2D) - static let surfaceElevated = Color(hex: 0x152338) - static let border = Color(hex: 0x24344D) - static let text = Color(hex: 0xEFF4FF) - static let textMuted = Color(hex: 0xA8B4C8) - static let primary = Color(hex: 0x5A8CFF) - static let accent = Color(hex: 0x5AE68C) - static let warning = Color(hex: 0xF59E0B) - static let zonework = Color(hex: 0x5A8CFF) - static let zonePersonal = Color(hex: 0x5AE68C) - static let zoneHealth = Color(hex: 0xFF6B6B) - static let zoneAdmin = Color(hex: 0xFECA57) - static let zoneLearning = Color(hex: 0xA66BFF) - static let urgentBadge = Color(hex: 0xFF6E6E) - static let scheduleEntry = Color(hex: 0x5A8CFF) - static let overflowWarning = Color(hex: 0xF59E0B) - static let recommendationInfo = Color(hex: 0x5A8CFF) - static let recommendationWarning = Color(hex: 0xF59E0B) - static let recommendationCritical = Color(hex: 0xFF6E6E) - -} - -enum FlowMonkColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum FlowMonkSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum FlowMonkRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum FlowMonkMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt deleted file mode 100644 index fdc64a4..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/FlowMonkTokens.generated.kt +++ /dev/null @@ -1,104 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: flowmonk -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.flowmonk.theme - -object FlowMonkTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Flowmonk Product Colors ─────────────────────────────── - object Product { - const val BG = 0xFF07111F - const val SURFACE = 0xFF0F1B2D - const val SURFACE_ELEVATED = 0xFF152338 - const val BORDER = 0xFF24344D - const val TEXT = 0xFFEFF4FF - const val TEXT_MUTED = 0xFFA8B4C8 - const val PRIMARY = 0xFF5A8CFF - const val ACCENT = 0xFF5AE68C - const val WARNING = 0xFFF59E0B - const val ZONEWORK = 0xFF5A8CFF - const val ZONE_PERSONAL = 0xFF5AE68C - const val ZONE_HEALTH = 0xFFFF6B6B - const val ZONE_ADMIN = 0xFFFECA57 - const val ZONE_LEARNING = 0xFFA66BFF - const val URGENT_BADGE = 0xFFFF6E6E - const val SCHEDULE_ENTRY = 0xFF5A8CFF - const val OVERFLOW_WARNING = 0xFFF59E0B - const val RECOMMENDATION_INFO = 0xFF5A8CFF - const val RECOMMENDATION_WARNING = 0xFFF59E0B - const val RECOMMENDATION_CRITICAL = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift deleted file mode 100644 index 0d88174..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTheme.generated.swift +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: jarvisjr -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum JarvisJrColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Jarvisjr Product Colors - static let accentPrimary = Color(hex: 0x7C6BFF) - static let accentSecondary = Color(hex: 0x5AE6C8) - static let accentVoice = Color(hex: 0xFF6B8A) - static let agentCoach = Color(hex: 0x5A8CFF) - static let agentLingua = Color(hex: 0xFFB74D) - static let agentSpark = Color(hex: 0xE040FB) - static let agentMentor = Color(hex: 0x34D399) - static let agentMirror = Color(hex: 0x2EE6D6) - static let agentOrator = Color(hex: 0xFF9F43) - -} - -enum JarvisJrColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum JarvisJrSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum JarvisJrRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum JarvisJrMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt deleted file mode 100644 index 6537682..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/JarvisJrTokens.generated.kt +++ /dev/null @@ -1,93 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: jarvisjr -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.jarvisjr.app.theme - -object JarvisJrTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Jarvisjr Product Colors ─────────────────────────────── - object Product { - const val ACCENT_PRIMARY = 0xFF7C6BFF - const val ACCENT_SECONDARY = 0xFF5AE6C8 - const val ACCENT_VOICE = 0xFFFF6B8A - const val AGENT_COACH = 0xFF5A8CFF - const val AGENT_LINGUA = 0xFFFFB74D - const val AGENT_SPARK = 0xFFE040FB - const val AGENT_MENTOR = 0xFF34D399 - const val AGENT_MIRROR = 0xFF2EE6D6 - const val AGENT_ORATOR = 0xFFFF9F43 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift deleted file mode 100644 index e75de29..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTheme.generated.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localllmlab -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LocalLLMLabColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Localllmlab Product Colors - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let borderSubtle = Color(hex: 0x1E293B) - static let borderDefault = Color(hex: 0x2A3654) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let purple = Color(hex: 0xA78BFA) - -} - -enum LocalLLMLabColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LocalLLMLabSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LocalLLMLabRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LocalLLMLabMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt deleted file mode 100644 index bac6a9b..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalLLMLabTokens.generated.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localllmlab -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.localllmlab.theme - -object LocalLLMLabTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Localllmlab Product Colors ─────────────────────────────── - object Product { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val BORDER_SUBTLE = 0xFF1E293B - const val BORDER_DEFAULT = 0xFF2A3654 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val PURPLE = 0xFFA78BFA - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift deleted file mode 100644 index 6c3d11c..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTheme.generated.swift +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localmemgpt -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LocalMemGPTColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Localmemgpt Product Colors - static let bgPrimary = Color(hex: 0x0A0A0A) - static let bgSecondary = Color(hex: 0x141414) - static let bgTertiary = Color(hex: 0x1E1E1E) - static let bgHover = Color(hex: 0x252525) - static let bgInput = Color(hex: 0x1A1A1A) - static let border = Color(hex: 0x2A2A2A) - static let textPrimary = Color(hex: 0xF0F0F0) - static let textSecondary = Color(hex: 0x999999) - static let textMuted = Color(hex: 0x666666) - static let accent = Color(hex: 0x6366F1) - static let accentHover = Color(hex: 0x818CF8) - static let success = Color(hex: 0x22C55E) - static let warning = Color(hex: 0xF59E0B) - static let error = Color(hex: 0xEF4444) - -} - -enum LocalMemGPTColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LocalMemGPTSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LocalMemGPTRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LocalMemGPTMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt deleted file mode 100644 index 51ae467..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LocalMemGPTTokens.generated.kt +++ /dev/null @@ -1,98 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: localmemgpt -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.localmemgpt.theme - -object LocalMemGPTTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Localmemgpt Product Colors ─────────────────────────────── - object Product { - const val BG_PRIMARY = 0xFF0A0A0A - const val BG_SECONDARY = 0xFF141414 - const val BG_TERTIARY = 0xFF1E1E1E - const val BG_HOVER = 0xFF252525 - const val BG_INPUT = 0xFF1A1A1A - const val BORDER = 0xFF2A2A2A - const val TEXT_PRIMARY = 0xFFF0F0F0 - const val TEXT_SECONDARY = 0xFF999999 - const val TEXT_MUTED = 0xFF666666 - const val ACCENT = 0xFF6366F1 - const val ACCENT_HOVER = 0xFF818CF8 - const val SUCCESS = 0xFF22C55E - const val WARNING = 0xFFF59E0B - const val ERROR = 0xFFEF4444 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift deleted file mode 100644 index ebe950e..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LysnrAITheme.generated.swift +++ /dev/null @@ -1,92 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: lysnrai -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum LysnrAIColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Lysnrai Product Colors - static let recordingActive = Color(hex: 0xFF6E6E) - static let recordingPaused = Color(hex: 0xFFD166) - static let processing = Color(hex: 0x5A8CFF) - static let transcribed = Color(hex: 0x34D399) - static let dictationMode = Color(hex: 0x7C6BFF) - static let commandMode = Color(hex: 0x2EE6D6) - static let hotkeyActive = Color(hex: 0xFF6B8A) - -} - -enum LysnrAIColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum LysnrAISpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum LysnrAIRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum LysnrAIMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt deleted file mode 100644 index 1fffbf3..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/LysnrAITokens.generated.kt +++ /dev/null @@ -1,91 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: lysnrai -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.saravana.lysnrai.theme - -object LysnrAITokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Lysnrai Product Colors ─────────────────────────────── - object Product { - const val RECORDING_ACTIVE = 0xFFFF6E6E - const val RECORDING_PAUSED = 0xFFFFD166 - const val PROCESSING = 0xFF5A8CFF - const val TRANSCRIBED = 0xFF34D399 - const val DICTATION_MODE = 0xFF7C6BFF - const val COMMAND_MODE = 0xFF2EE6D6 - const val HOTKEY_ACTIVE = 0xFFFF6B8A - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift deleted file mode 100644 index c681d96..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NomGapTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: nomgap -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum NomGapColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Nomgap Product Colors - static let stageFed = Color(hex: 0xFF9F43) - static let stageEarlyFast = Color(hex: 0xFECA57) - static let stageFasted = Color(hex: 0x48DBFB) - static let stageKetosis = Color(hex: 0x5A8CFF) - static let stageDeepAutophagy = Color(hex: 0xA66BFF) - static let stageExtended = Color(hex: 0xFFD700) - static let autophagyMeter = Color(hex: 0x5AE68C) - static let hydrationReminder = Color(hex: 0x48DBFB) - static let electrolyteAlert = Color(hex: 0xFF9F43) - static let safetyWarning = Color(hex: 0xFF6E6E) - -} - -enum NomGapColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum NomGapSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum NomGapRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum NomGapMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt deleted file mode 100644 index d7c9964..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NomGapTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: nomgap -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.nomgap.theme - -object NomGapTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Nomgap Product Colors ─────────────────────────────── - object Product { - const val STAGE_FED = 0xFFFF9F43 - const val STAGE_EARLY_FAST = 0xFFFECA57 - const val STAGE_FASTED = 0xFF48DBFB - const val STAGE_KETOSIS = 0xFF5A8CFF - const val STAGE_DEEP_AUTOPHAGY = 0xFFA66BFF - const val STAGE_EXTENDED = 0xFFFFD700 - const val AUTOPHAGY_METER = 0xFF5AE68C - const val HYDRATION_REMINDER = 0xFF48DBFB - const val ELECTROLYTE_ALERT = 0xFFFF9F43 - const val SAFETY_WARNING = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift deleted file mode 100644 index eacca13..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NoteLettTheme.generated.swift +++ /dev/null @@ -1,100 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: notelett -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum NoteLettColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Notelett Product Colors - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - static let focusRing = Color(hex: 0x5A8CFF) - static let agentAction = Color(hex: 0xA66BFF) - static let draftNote = Color(hex: 0xFFD166) - static let linkedNote = Color(hex: 0x2EE6D6) - static let taskPending = Color(hex: 0xF59E0B) - static let taskComplete = Color(hex: 0x34D399) - -} - -enum NoteLettColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum NoteLettSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum NoteLettRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum NoteLettMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt deleted file mode 100644 index 700b3cc..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/NoteLettTokens.generated.kt +++ /dev/null @@ -1,99 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: notelett -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.notelett.theme - -object NoteLettTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Notelett Product Colors ─────────────────────────────── - object Product { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - const val FOCUS_RING = 0xFF5A8CFF - const val AGENT_ACTION = 0xFFA66BFF - const val DRAFT_NOTE = 0xFFFFD166 - const val LINKED_NOTE = 0xFF2EE6D6 - const val TASK_PENDING = 0xFFF59E0B - const val TASK_COMPLETE = 0xFF34D399 - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift deleted file mode 100644 index ffbec17..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTheme.generated.swift +++ /dev/null @@ -1,95 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: peakpulse -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts - -import SwiftUI - -enum PeakPulseColors { - // MARK: - Semantic (Dark Theme) - static let bgCanvas = Color(hex: 0x06070A) - static let bgElevated = Color(hex: 0x0E1118) - static let surfaceCard = Color(hex: 0x121725) - static let surfaceMuted = Color(hex: 0x1A2335) - static let textPrimary = Color(hex: 0xEFF4FF) - static let textSecondary = Color(hex: 0xA5B1C7) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x34D399) - static let warning = Color(hex: 0xF59E0B) - static let danger = Color(hex: 0xFF6E6E) - - // MARK: - Peakpulse Product Colors - static let activityHike = Color(hex: 0x34D399) - static let activitySki = Color(hex: 0x5A8CFF) - static let speedZoneSlow = Color(hex: 0x34D399) - static let speedZoneFast = Color(hex: 0xFFD166) - static let speedZoneDanger = Color(hex: 0xFF6E6E) - static let elevationGain = Color(hex: 0x2EE6D6) - static let elevationLoss = Color(hex: 0xFF9F43) - static let personalBest = Color(hex: 0xFFD700) - static let streakActive = Color(hex: 0x34D399) - static let streakBroken = Color(hex: 0xFF6E6E) - -} - -enum PeakPulseColorsLight { - // MARK: - Semantic (Light Theme) - static let bgCanvas = Color(hex: 0xF6F8FC) - static let bgElevated = Color(hex: 0xEEF2FA) - static let surfaceCard = Color(hex: 0xFFFFFF) - static let surfaceMuted = Color(hex: 0xF3F5FA) - static let textPrimary = Color(hex: 0x0E1320) - static let textSecondary = Color(hex: 0x55637A) - static let textTertiary = Color(hex: 0x6C7C98) - static let accentPrimary = Color(hex: 0x5A8CFF) - static let accentSecondary = Color(hex: 0x2EE6D6) - static let success = Color(hex: 0x13956A) - static let warning = Color(hex: 0xB87504) - static let danger = Color(hex: 0xD24242) -} - -enum PeakPulseSpacing { - static let x0: CGFloat = 0 - static let x1: CGFloat = 4 - static let x2: CGFloat = 8 - static let x3: CGFloat = 12 - static let x4: CGFloat = 16 - static let x5: CGFloat = 20 - static let x6: CGFloat = 24 - static let x7: CGFloat = 28 - static let x8: CGFloat = 32 - static let x10: CGFloat = 40 - static let x12: CGFloat = 48 - static let x16: CGFloat = 64 -} - -enum PeakPulseRadius { - static let xs: CGFloat = 8 - static let sm: CGFloat = 12 - static let md: CGFloat = 16 - static let lg: CGFloat = 20 - static let xl: CGFloat = 24 - static let pill: CGFloat = 999 -} - -enum PeakPulseMotion { - static let instant: Double = 0.07 - static let fast: Double = 0.14 - static let base: Double = 0.22 - static let slow: Double = 0.32 -} - -// MARK: - Color Hex Extension (import if not already defined) - -extension Color { - init(hex: UInt, alpha: Double = 1.0) { - self.init( - .sRGB, - red: Double((hex >> 16) & 0xFF) / 255.0, - green: Double((hex >> 8) & 0xFF) / 255.0, - blue: Double(hex & 0xFF) / 255.0, - opacity: alpha - ) - } -} diff --git a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt b/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt deleted file mode 100644 index 2848bdd..0000000 --- a/vendor/bytelyst/design-tokens/generated/native/PeakPulseTokens.generated.kt +++ /dev/null @@ -1,94 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually. -// Product: peakpulse -// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts -package com.peakpulse.theme - -object PeakPulseTokens { - - // ── Semantic Colors (Dark Theme) ───────────────────────────────── - object Dark { - const val BG_CANVAS = 0xFF06070A - const val BG_ELEVATED = 0xFF0E1118 - const val SURFACE_CARD = 0xFF121725 - const val SURFACE_MUTED = 0xFF1A2335 - const val TEXT_PRIMARY = 0xFFEFF4FF - const val TEXT_SECONDARY = 0xFFA5B1C7 - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF34D399 - const val WARNING = 0xFFF59E0B - const val DANGER = 0xFFFF6E6E - } - - // ── Semantic Colors (Light Theme) ──────────────────────────────── - object Light { - const val BG_CANVAS = 0xFFF6F8FC - const val BG_ELEVATED = 0xFFEEF2FA - const val SURFACE_CARD = 0xFFFFFFFF - const val SURFACE_MUTED = 0xFFF3F5FA - const val TEXT_PRIMARY = 0xFF0E1320 - const val TEXT_SECONDARY = 0xFF55637A - const val TEXT_TERTIARY = 0xFF6C7C98 - const val ACCENT_PRIMARY = 0xFF5A8CFF - const val ACCENT_SECONDARY = 0xFF2EE6D6 - const val SUCCESS = 0xFF13956A - const val WARNING = 0xFFB87504 - const val DANGER = 0xFFD24242 - } - - // ── Peakpulse Product Colors ─────────────────────────────── - object Product { - const val ACTIVITY_HIKE = 0xFF34D399 - const val ACTIVITY_SKI = 0xFF5A8CFF - const val SPEED_ZONE_SLOW = 0xFF34D399 - const val SPEED_ZONE_FAST = 0xFFFFD166 - const val SPEED_ZONE_DANGER = 0xFFFF6E6E - const val ELEVATION_GAIN = 0xFF2EE6D6 - const val ELEVATION_LOSS = 0xFFFF9F43 - const val PERSONAL_BEST = 0xFFFFD700 - const val STREAK_ACTIVE = 0xFF34D399 - const val STREAK_BROKEN = 0xFFFF6E6E - } - - // ── Spacing (8pt grid) ─────────────────────────────────────────── - object Spacing { - const val X0 = 0 - const val X1 = 4 - const val X2 = 8 - const val X3 = 12 - const val X4 = 16 - const val X5 = 20 - const val X6 = 24 - const val X7 = 28 - const val X8 = 32 - const val X10 = 40 - const val X12 = 48 - const val X16 = 64 - } - - // ── Radius ─────────────────────────────────────────────────────── - object Radius { - const val XS = 8 - const val SM = 12 - const val MD = 16 - const val LG = 20 - const val XL = 24 - const val PILL = 999 - } - - // ── Typography ─────────────────────────────────────────────────── - object Typography { - const val FONT_DISPLAY = "Space Grotesk" - const val FONT_BODY = "DM Sans" - const val FONT_MONO = "IBM Plex Mono" - } - - // ── Motion ─────────────────────────────────────────────────────── - object Motion { - const val INSTANT = 70 - const val FAST = 140 - const val BASE = 220 - const val SLOW = 320 - } -} diff --git a/vendor/bytelyst/design-tokens/generated/nomgap.css b/vendor/bytelyst/design-tokens/generated/nomgap.css deleted file mode 100644 index 18294b6..0000000 --- a/vendor/bytelyst/design-tokens/generated/nomgap.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated nomgap tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --ng-bg-canvas: #06070A; - --ng-bg-elevated: #0E1118; - --ng-surface-card: #121725; - --ng-surface-muted: #1A2335; - --ng-border-default: rgba(255,255,255,0.12); - --ng-border-strong: rgba(255,255,255,0.22); - --ng-text-primary: #EFF4FF; - --ng-text-secondary: #A5B1C7; - --ng-text-tertiary: #6C7C98; - --ng-accent-primary: #5A8CFF; - --ng-accent-secondary: #2EE6D6; - --ng-success: #34D399; - --ng-warning: #F59E0B; - --ng-danger: #FF6E6E; - --ng-focus-ring: rgba(90,140,255,0.45); - --ng-overlay-scrim: rgba(5,8,18,0.72); - - /* nomgap product colors */ - --ng-stage-fed: #FF9F43; - --ng-stage-early-fast: #FECA57; - --ng-stage-fasted: #48DBFB; - --ng-stage-ketosis: #5A8CFF; - --ng-stage-deep-autophagy: #A66BFF; - --ng-stage-extended: #FFD700; - --ng-autophagy-meter: #5AE68C; - --ng-hydration-reminder: #48DBFB; - --ng-electrolyte-alert: #FF9F43; - --ng-safety-warning: #FF6E6E; - - --ng-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ng-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ng-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ng-fs-xs: 12px; - --ng-fs-sm: 14px; - --ng-fs-md: 16px; - --ng-fs-lg: 18px; - --ng-fs-xl: 22px; - --ng-fs-2xl: 28px; - --ng-fs-3xl: 36px; - - --ng-space-0: 0; - --ng-space-1: 4px; - --ng-space-2: 8px; - --ng-space-3: 12px; - --ng-space-4: 16px; - --ng-space-5: 20px; - --ng-space-6: 24px; - --ng-space-7: 28px; - --ng-space-8: 32px; - --ng-space-10: 40px; - --ng-space-12: 48px; - --ng-space-16: 64px; - - --ng-radius-xs: 8px; - --ng-radius-sm: 12px; - --ng-radius-md: 16px; - --ng-radius-lg: 20px; - --ng-radius-xl: 24px; - --ng-radius-pill: 999px; - - --ng-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ng-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ng-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ng-motion-fast: 140ms; - --ng-motion-base: 220ms; - --ng-motion-slow: 320ms; - --ng-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ng-bg-canvas: #F6F8FC; - --ng-bg-elevated: #EEF2FA; - --ng-surface-card: #FFFFFF; - --ng-surface-muted: #F3F5FA; - --ng-border-default: rgba(14,19,32,0.12); - --ng-border-strong: rgba(14,19,32,0.24); - --ng-text-primary: #0E1320; - --ng-text-secondary: #55637A; - --ng-success: #13956A; - --ng-warning: #B87504; - --ng-danger: #D24242; - --ng-focus-ring: rgba(90,140,255,0.35); - --ng-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/notelett.css b/vendor/bytelyst/design-tokens/generated/notelett.css deleted file mode 100644 index c81806f..0000000 --- a/vendor/bytelyst/design-tokens/generated/notelett.css +++ /dev/null @@ -1,94 +0,0 @@ -/* Auto-generated notelett tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --nl-bg-canvas: #06070A; - --nl-bg-elevated: #0E1118; - --nl-surface-card: #121725; - --nl-surface-muted: #1A2335; - --nl-border-default: rgba(255,255,255,0.12); - --nl-border-strong: rgba(255,255,255,0.22); - --nl-text-primary: #EFF4FF; - --nl-text-secondary: #A5B1C7; - --nl-text-tertiary: #6C7C98; - --nl-accent-primary: #5A8CFF; - --nl-accent-secondary: #2EE6D6; - --nl-success: #34D399; - --nl-warning: #F59E0B; - --nl-danger: #FF6E6E; - --nl-focus-ring: rgba(90,140,255,0.45); - --nl-overlay-scrim: rgba(5,8,18,0.72); - - /* notelett product colors */ - --nl-bg-canvas: #06070A; - --nl-bg-elevated: #0E1118; - --nl-surface-card: #121725; - --nl-surface-muted: #1A2335; - --nl-accent-primary: #5A8CFF; - --nl-accent-secondary: #2EE6D6; - --nl-success: #34D399; - --nl-warning: #F59E0B; - --nl-danger: #FF6E6E; - --nl-focus-ring: #5A8CFF; - --nl-agent-action: #A66BFF; - --nl-draft-note: #FFD166; - --nl-linked-note: #2EE6D6; - --nl-task-pending: #F59E0B; - --nl-task-complete: #34D399; - - --nl-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --nl-font-body: "DM Sans", "SF Pro Text", sans-serif; - --nl-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --nl-fs-xs: 12px; - --nl-fs-sm: 14px; - --nl-fs-md: 16px; - --nl-fs-lg: 18px; - --nl-fs-xl: 22px; - --nl-fs-2xl: 28px; - --nl-fs-3xl: 36px; - - --nl-space-0: 0; - --nl-space-1: 4px; - --nl-space-2: 8px; - --nl-space-3: 12px; - --nl-space-4: 16px; - --nl-space-5: 20px; - --nl-space-6: 24px; - --nl-space-7: 28px; - --nl-space-8: 32px; - --nl-space-10: 40px; - --nl-space-12: 48px; - --nl-space-16: 64px; - - --nl-radius-xs: 8px; - --nl-radius-sm: 12px; - --nl-radius-md: 16px; - --nl-radius-lg: 20px; - --nl-radius-xl: 24px; - --nl-radius-pill: 999px; - - --nl-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --nl-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --nl-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --nl-motion-fast: 140ms; - --nl-motion-base: 220ms; - --nl-motion-slow: 320ms; - --nl-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --nl-bg-canvas: #F6F8FC; - --nl-bg-elevated: #EEF2FA; - --nl-surface-card: #FFFFFF; - --nl-surface-muted: #F3F5FA; - --nl-border-default: rgba(14,19,32,0.12); - --nl-border-strong: rgba(14,19,32,0.24); - --nl-text-primary: #0E1320; - --nl-text-secondary: #55637A; - --nl-success: #13956A; - --nl-warning: #B87504; - --nl-danger: #D24242; - --nl-focus-ring: rgba(90,140,255,0.35); - --nl-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/peakpulse.css b/vendor/bytelyst/design-tokens/generated/peakpulse.css deleted file mode 100644 index 24cf13a..0000000 --- a/vendor/bytelyst/design-tokens/generated/peakpulse.css +++ /dev/null @@ -1,89 +0,0 @@ -/* Auto-generated peakpulse tokens from bytelyst.tokens.json — do not edit manually */ - -:root { - --pp-bg-canvas: #06070A; - --pp-bg-elevated: #0E1118; - --pp-surface-card: #121725; - --pp-surface-muted: #1A2335; - --pp-border-default: rgba(255,255,255,0.12); - --pp-border-strong: rgba(255,255,255,0.22); - --pp-text-primary: #EFF4FF; - --pp-text-secondary: #A5B1C7; - --pp-text-tertiary: #6C7C98; - --pp-accent-primary: #5A8CFF; - --pp-accent-secondary: #2EE6D6; - --pp-success: #34D399; - --pp-warning: #F59E0B; - --pp-danger: #FF6E6E; - --pp-focus-ring: rgba(90,140,255,0.45); - --pp-overlay-scrim: rgba(5,8,18,0.72); - - /* peakpulse product colors */ - --pp-activity-hike: #34D399; - --pp-activity-ski: #5A8CFF; - --pp-speed-zone-slow: #34D399; - --pp-speed-zone-fast: #FFD166; - --pp-speed-zone-danger: #FF6E6E; - --pp-elevation-gain: #2EE6D6; - --pp-elevation-loss: #FF9F43; - --pp-personal-best: #FFD700; - --pp-streak-active: #34D399; - --pp-streak-broken: #FF6E6E; - - --pp-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --pp-font-body: "DM Sans", "SF Pro Text", sans-serif; - --pp-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --pp-fs-xs: 12px; - --pp-fs-sm: 14px; - --pp-fs-md: 16px; - --pp-fs-lg: 18px; - --pp-fs-xl: 22px; - --pp-fs-2xl: 28px; - --pp-fs-3xl: 36px; - - --pp-space-0: 0; - --pp-space-1: 4px; - --pp-space-2: 8px; - --pp-space-3: 12px; - --pp-space-4: 16px; - --pp-space-5: 20px; - --pp-space-6: 24px; - --pp-space-7: 28px; - --pp-space-8: 32px; - --pp-space-10: 40px; - --pp-space-12: 48px; - --pp-space-16: 64px; - - --pp-radius-xs: 8px; - --pp-radius-sm: 12px; - --pp-radius-md: 16px; - --pp-radius-lg: 20px; - --pp-radius-xl: 24px; - --pp-radius-pill: 999px; - - --pp-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --pp-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --pp-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --pp-motion-fast: 140ms; - --pp-motion-base: 220ms; - --pp-motion-slow: 320ms; - --pp-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --pp-bg-canvas: #F6F8FC; - --pp-bg-elevated: #EEF2FA; - --pp-surface-card: #FFFFFF; - --pp-surface-muted: #F3F5FA; - --pp-border-default: rgba(14,19,32,0.12); - --pp-border-strong: rgba(14,19,32,0.24); - --pp-text-primary: #0E1320; - --pp-text-secondary: #55637A; - --pp-success: #13956A; - --pp-warning: #B87504; - --pp-danger: #D24242; - --pp-focus-ring: rgba(90,140,255,0.35); - --pp-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts b/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts deleted file mode 100644 index ddb25ca..0000000 --- a/vendor/bytelyst/design-tokens/generated/react-native/tokens.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * React Native Design Tokens — Auto-generated from bytelyst.tokens.json - * Do not edit manually. Run: tsx scripts/generate-react-native.ts - */ - -export const tokens = { - // ── Semantic Colors (Dark Theme) ────────────────────────────────── - colors: { - bgCanvas: '#06070A', - bgElevated: '#0E1118', - surfaceCard: '#121725', - surfaceMuted: '#1A2335', - borderDefault: '#1FFFFFFF', - borderStrong: '#38FFFFFF', - textPrimary: '#EFF4FF', - textSecondary: '#A5B1C7', - textTertiary: '#6C7C98', - accentPrimary: '#5A8CFF', - accentSecondary: '#2EE6D6', - success: '#34D399', - warning: '#F59E0B', - danger: '#FF6E6E', - focusRing: '#735A8CFF', - overlayScrim: '#B8050812', - }, - - // ── NomGap Product Colors ────────────────────────────────────────── - nomgap: { - stageFed: '#FF9F43', - stageEarlyFast: '#FECA57', - stageFasted: '#48DBFB', - stageKetosis: '#5A8CFF', - stageDeepAutophagy: '#A66BFF', - stageExtended: '#FFD700', - autophagyMeter: '#5AE68C', - hydrationReminder: '#48DBFB', - electrolyteAlert: '#FF9F43', - safetyWarning: '#FF6E6E', - }, - - // ── Spacing (8pt grid) ────────────────────────────────────────────── - spacing: { - 0: 0, - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 5: 20, - 6: 24, - 7: 28, - 8: 32, - 10: 40, - 12: 48, - 16: 64, - }, - - // ── Border Radius ─────────────────────────────────────────────────── - radius: { - xs: 8, - sm: 12, - md: 16, - lg: 20, - xl: 24, - pill: 999, - }, - - // ── Typography ───────────────────────────────────────────────────── - typography: { - fontFamily: { - display: 'System', - body: 'System', - mono: 'Courier', - }, - fontSize: { - xs: 12, - sm: 14, - md: 16, - lg: 18, - xl: 22, - '2xl': 28, - '3xl': 36, - }, - fontWeight: { - regular: '400', - medium: '500', - semibold: '600', - bold: '700', - }, - }, - - // ── Icon Sizes ────────────────────────────────────────────────────── - icon: { - xs: 12, - sm: 16, - md: 20, - lg: 24, - xl: 32, - '2xl': 48, - }, - - // ── Z-Index Layers ─────────────────────────────────────────────────── - zIndex: { - hidden: -1, - base: 0, - dropdown: 100, - sticky: 200, - fixed: 300, - overlay: 400, - modal: 500, - popover: 600, - toast: 700, - tooltip: 800, - }, - - // ── Opacity ───────────────────────────────────────────────────────── - opacity: { - '0': 0, - '10': 0.1, - '20': 0.2, - '30': 0.3, - '40': 0.4, - '50': 0.5, - '60': 0.6, - '70': 0.7, - '80': 0.8, - '90': 0.9, - '100': 1, - }, - - // ── Motion ──────────────────────────────────────────────────────────── - motion: { - instant: 70, - fast: 140, - base: 220, - slow: 320, - }, -} as const; - -export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/generated/tokens.css b/vendor/bytelyst/design-tokens/generated/tokens.css deleted file mode 100644 index 27d5710..0000000 --- a/vendor/bytelyst/design-tokens/generated/tokens.css +++ /dev/null @@ -1,78 +0,0 @@ -/* Auto-generated from bytelyst.tokens.json — do not edit manually */ - -:root, -[data-theme="dark"] { - --ml-bg-canvas: #06070A; - --ml-bg-elevated: #0E1118; - --ml-surface-card: #121725; - --ml-surface-muted: #1A2335; - --ml-border-default: rgba(255,255,255,0.12); - --ml-border-strong: rgba(255,255,255,0.22); - --ml-text-primary: #EFF4FF; - --ml-text-secondary: #A5B1C7; - --ml-text-tertiary: #6C7C98; - --ml-accent-primary: #5A8CFF; - --ml-accent-secondary: #2EE6D6; - --ml-success: #34D399; - --ml-warning: #F59E0B; - --ml-danger: #FF6E6E; - --ml-focus-ring: rgba(90,140,255,0.45); - --ml-overlay-scrim: rgba(5,8,18,0.72); - - --ml-font-display: "Space Grotesk", "SF Pro Display", sans-serif; - --ml-font-body: "DM Sans", "SF Pro Text", sans-serif; - --ml-font-mono: "IBM Plex Mono", "SF Mono", monospace; - - --ml-fs-xs: 12px; - --ml-fs-sm: 14px; - --ml-fs-md: 16px; - --ml-fs-lg: 18px; - --ml-fs-xl: 22px; - --ml-fs-2xl: 28px; - --ml-fs-3xl: 36px; - - --ml-space-0: 0; - --ml-space-1: 4px; - --ml-space-2: 8px; - --ml-space-3: 12px; - --ml-space-4: 16px; - --ml-space-5: 20px; - --ml-space-6: 24px; - --ml-space-7: 28px; - --ml-space-8: 32px; - --ml-space-10: 40px; - --ml-space-12: 48px; - --ml-space-16: 64px; - - --ml-radius-xs: 8px; - --ml-radius-sm: 12px; - --ml-radius-md: 16px; - --ml-radius-lg: 20px; - --ml-radius-xl: 24px; - --ml-radius-pill: 999px; - - --ml-elevation-sm: 0 4px 12px rgba(0,0,0,0.12); - --ml-elevation-md: 0 12px 28px rgba(0,0,0,0.18); - --ml-elevation-lg: 0 20px 48px rgba(0,0,0,0.24); - - --ml-motion-fast: 140ms; - --ml-motion-base: 220ms; - --ml-motion-slow: 320ms; - --ml-easing-standard: cubic-bezier(0.2, 0.0, 0.2, 1); -} - -[data-theme="light"] { - --ml-bg-canvas: #F6F8FC; - --ml-bg-elevated: #EEF2FA; - --ml-surface-card: #FFFFFF; - --ml-surface-muted: #F3F5FA; - --ml-border-default: rgba(14,19,32,0.12); - --ml-border-strong: rgba(14,19,32,0.24); - --ml-text-primary: #0E1320; - --ml-text-secondary: #55637A; - --ml-success: #13956A; - --ml-warning: #B87504; - --ml-danger: #D24242; - --ml-focus-ring: rgba(90,140,255,0.35); - --ml-overlay-scrim: rgba(10,13,23,0.5); -} diff --git a/vendor/bytelyst/design-tokens/generated/tokens.ts b/vendor/bytelyst/design-tokens/generated/tokens.ts deleted file mode 100644 index 6a79b38..0000000 --- a/vendor/bytelyst/design-tokens/generated/tokens.ts +++ /dev/null @@ -1,386 +0,0 @@ -// Auto-generated from bytelyst.tokens.json — do not edit manually - -export const tokens = { - "meta": { - "name": "ByteLyst Design Tokens", - "version": "1.1.0", - "updatedAt": "2026-03-03", - "scale": "8pt" - }, - "color": { - "palette": { - "neutral": { - "0": "#FFFFFF", - "50": "#F6F8FC", - "100": "#EEF2FA", - "200": "#DCE4F2", - "300": "#BFCBDE", - "400": "#92A1BA", - "500": "#6C7C98", - "600": "#55637A", - "700": "#3B455A", - "800": "#1A2335", - "900": "#0E1320", - "950": "#06070A" - }, - "brand": { - "blue": "#5A8CFF", - "cyan": "#2EE6D6", - "coral": "#FF6E6E", - "gold": "#FFD166", - "mint": "#34D399", - "warning": "#F59E0B", - "microsoftRed": "#F25022", - "microsoftGreen": "#7FBA00", - "microsoftBlue": "#00A4EF", - "microsoftYellow": "#FFB900", - "googleBlue": "#4285F4", - "googleGreen": "#34A853", - "googleYellow": "#FBBC05", - "googleRed": "#EA4335" - } - }, - "semantic": { - "dark": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderDefault": "rgba(255,255,255,0.12)", - "borderStrong": "rgba(255,255,255,0.22)", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "rgba(90,140,255,0.45)", - "overlayScrim": "rgba(5,8,18,0.72)" - }, - "light": { - "bgCanvas": "#F6F8FC", - "bgElevated": "#EEF2FA", - "surfaceCard": "#FFFFFF", - "surfaceMuted": "#F3F5FA", - "borderDefault": "rgba(14,19,32,0.12)", - "borderStrong": "rgba(14,19,32,0.24)", - "textPrimary": "#0E1320", - "textSecondary": "#55637A", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#13956A", - "warning": "#B87504", - "danger": "#D24242", - "focusRing": "rgba(90,140,255,0.35)", - "overlayScrim": "rgba(10,13,23,0.5)" - } - }, - "brain": { - "work": { - "from": "#5A8CFF", - "to": "#2EE6D6" - }, - "home": { - "from": "#FF6E6E", - "to": "#FFD166" - }, - "money": { - "from": "#34D399", - "to": "#2EE6D6" - }, - "health": { - "from": "#2EE6D6", - "to": "#9FE870" - }, - "global": { - "from": "#7D8FB4", - "to": "#A5B1C7" - } - }, - "jarvisjr": { - "accentPrimary": "#7C6BFF", - "accentSecondary": "#5AE6C8", - "accentVoice": "#FF6B8A", - "agentCoach": "#5A8CFF", - "agentLingua": "#FFB74D", - "agentSpark": "#E040FB", - "agentMentor": "#34D399", - "agentMirror": "#2EE6D6", - "agentOrator": "#FF9F43" - }, - "peakpulse": { - "activityHike": "#34D399", - "activitySki": "#5A8CFF", - "speedZoneSlow": "#34D399", - "speedZoneFast": "#FFD166", - "speedZoneDanger": "#FF6E6E", - "elevationGain": "#2EE6D6", - "elevationLoss": "#FF9F43", - "personalBest": "#FFD700", - "streakActive": "#34D399", - "streakBroken": "#FF6E6E" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "urgencyImportant": "#FFD166", - "urgencyStandard": "#5A8CFF", - "urgencyGentle": "#34D399", - "urgencyPassive": "#A5B1C7", - "focusMode": "#7C6BFF", - "pomodoroWork": "#34D399", - "pomodoroBreak": "#5A8CFF", - "cascadeWarning": "#FF9F43", - "timerComplete": "#34D399" - }, - "nomgap": { - "stageFed": "#FF9F43", - "stageEarlyFast": "#FECA57", - "stageFasted": "#48DBFB", - "stageKetosis": "#5A8CFF", - "stageDeepAutophagy": "#A66BFF", - "stageExtended": "#FFD700", - "autophagyMeter": "#5AE68C", - "hydrationReminder": "#48DBFB", - "electrolyteAlert": "#FF9F43", - "safetyWarning": "#FF6E6E" - }, - "lysnrai": { - "recordingActive": "#FF6E6E", - "recordingPaused": "#FFD166", - "processing": "#5A8CFF", - "transcribed": "#34D399", - "dictationMode": "#7C6BFF", - "commandMode": "#2EE6D6", - "hotkeyActive": "#FF6B8A" - }, - "flowmonk": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "zonework": "#5A8CFF", - "zonePersonal": "#5AE68C", - "zoneHealth": "#FF6B6B", - "zoneAdmin": "#FECA57", - "zoneLearning": "#A66BFF", - "urgentBadge": "#FF6E6E", - "scheduleEntry": "#5A8CFF", - "overflowWarning": "#F59E0B", - "recommendationInfo": "#5A8CFF", - "recommendationWarning": "#F59E0B", - "recommendationCritical": "#FF6E6E" - }, - "actiontrail": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "riskLow": "#5AE68C", - "riskMedium": "#F59E0B", - "riskHigh": "#FF8C42", - "riskCritical": "#FF6E6E", - "statusPending": "#F59E0B", - "statusApplied": "#5AE68C", - "statusRejected": "#FF6E6E", - "statusReverted": "#A66BFF" - }, - "notelett": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "#5A8CFF", - "agentAction": "#A66BFF", - "draftNote": "#FFD166", - "linkedNote": "#2EE6D6", - "taskPending": "#F59E0B", - "taskComplete": "#34D399" - }, - "localmemgpt": { - "bgPrimary": "#0A0A0A", - "bgSecondary": "#141414", - "bgTertiary": "#1E1E1E", - "bgHover": "#252525", - "bgInput": "#1A1A1A", - "border": "#2A2A2A", - "textPrimary": "#F0F0F0", - "textSecondary": "#999999", - "textMuted": "#666666", - "accent": "#6366F1", - "accentHover": "#818CF8", - "success": "#22C55E", - "warning": "#F59E0B", - "error": "#EF4444" - }, - "localllmlab": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderSubtle": "#1E293B", - "borderDefault": "#2A3654", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "purple": "#A78BFA" - } - }, - "typography": { - "fontFamily": { - "display": "'Space Grotesk', 'SF Pro Display', sans-serif", - "body": "'DM Sans', 'SF Pro Text', sans-serif", - "mono": "'IBM Plex Mono', 'SF Mono', monospace" - }, - "fontWeight": { - "regular": 400, - "medium": 500, - "semibold": 600, - "bold": 700 - }, - "fontSize": { - "xs": 12, - "sm": 14, - "md": 16, - "lg": 18, - "xl": 22, - "2xl": 28, - "3xl": 36 - }, - "lineHeight": { - "tight": 1.2, - "normal": 1.45, - "relaxed": 1.65 - }, - "letterSpacing": { - "tight": -0.02, - "normal": 0, - "wide": 0.02 - } - }, - "spacing": { - "0": 0, - "1": 4, - "2": 8, - "3": 12, - "4": 16, - "5": 20, - "6": 24, - "7": 28, - "8": 32, - "10": 40, - "12": 48, - "16": 64 - }, - "radius": { - "xs": 8, - "sm": 12, - "md": 16, - "lg": 20, - "xl": 24, - "pill": 999 - }, - "elevation": { - "none": "0 0 0 rgba(0,0,0,0)", - "sm": "0 4px 12px rgba(0,0,0,0.12)", - "md": "0 12px 28px rgba(0,0,0,0.18)", - "lg": "0 20px 48px rgba(0,0,0,0.24)" - }, - "motion": { - "duration": { - "instant": 70, - "fast": 140, - "base": 220, - "slow": 320 - }, - "easing": { - "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", - "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", - "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" - } - }, - "breakpoints": { - "mobile": 0, - "tablet": 768, - "desktop": 1200, - "wide": 1440 - }, - "layout": { - "maxContentWidth": 1280, - "mobileGutter": 16, - "tabletGutter": 24, - "desktopGutter": 32, - "touchTargetMin": 44 - }, - "zIndex": { - "hidden": -1, - "base": 0, - "dropdown": 100, - "sticky": 200, - "fixed": 300, - "overlay": 400, - "modal": 500, - "popover": 600, - "toast": 700, - "tooltip": 800 - }, - "icon": { - "xs": 12, - "sm": 16, - "md": 20, - "lg": 24, - "xl": 32, - "2xl": 48 - }, - "grid": { - "columns": 12, - "gutter": 24, - "maxWidth": 1200, - "breakpoints": { - "xs": 0, - "sm": 576, - "md": 768, - "lg": 992, - "xl": 1200, - "xxl": 1400 - } - }, - "opacity": { - "0": 0, - "10": 0.1, - "20": 0.2, - "30": 0.3, - "40": 0.4, - "50": 0.5, - "60": 0.6, - "70": 0.7, - "80": 0.8, - "90": 0.9, - "100": 1 - } -} as const; - -export type Tokens = typeof tokens; diff --git a/vendor/bytelyst/design-tokens/package.json b/vendor/bytelyst/design-tokens/package.json deleted file mode 100644 index 55a6e3a..0000000 --- a/vendor/bytelyst/design-tokens/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@bytelyst/design-tokens", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./tokens.json": "./tokens/bytelyst.tokens.json", - "./generated/tokens": "./generated/tokens.ts", - "./css": "./generated/tokens.css", - "./css/chronomind": "./generated/chronomind.css", - "./css/jarvisjr": "./generated/jarvisjr.css", - "./css/nomgap": "./generated/nomgap.css", - "./css/actiontrail": "./generated/actiontrail.css", - "./css/flowmonk": "./generated/flowmonk.css", - "./css/notelett": "./generated/notelett.css", - "./css/localmemgpt": "./generated/localmemgpt.css", - "./css/localllmlab": "./generated/localllmlab.css", - "./css/lysnrai": "./generated/lysnrai.css", - "./css/peakpulse": "./generated/peakpulse.css", - "./css/mindlyst": "./generated/mindlyst.css" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "tokens", - "generated", - "scripts" - ], - "scripts": { - "build": "tsc", - "generate": "tsx scripts/generate.ts", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "tsx": "^4.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts b/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts deleted file mode 100644 index 55e5ba6..0000000 --- a/vendor/bytelyst/design-tokens/scripts/generate-react-native.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * React Native token generator for Expo/NomGap - * Reads bytelyst.tokens.json, outputs RN StyleSheet-compatible tokens - * - * Usage: tsx scripts/generate-react-native.ts - */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); -const outDir = resolve(__dirname, '../generated/react-native'); - -mkdirSync(outDir, { recursive: true }); - -const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); - -// ── Helpers ─────────────────────────────────────────────────────────── - -function generateReactNative(): string { - const lines: string[] = [ - '/**', - ' * React Native Design Tokens — Auto-generated from bytelyst.tokens.json', - ' * Do not edit manually. Run: tsx scripts/generate-react-native.ts', - ' */', - '', - 'export const tokens = {', - '', - ' // ── Semantic Colors (Dark Theme) ──────────────────────────────────', - ' colors: {', - ]; - - // Semantic dark colors - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` ${key}: '${value}',`); - } else if (typeof value === 'string' && value.startsWith('rgba')) { - // Convert rgba to hex8 for React Native - const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); - if (match) { - const [, r, g, b, a] = match; - const alpha = Math.round(parseFloat(a) * 255) - .toString(16) - .padStart(2, '0'); - const hex = - `#${alpha}${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`.toUpperCase(); - lines.push(` ${key}: '${hex}',`); - } - } - } - lines.push(' },', ''); - - // NomGap-specific colors - lines.push(' // ── NomGap Product Colors ──────────────────────────────────────────'); - lines.push(' nomgap: {'); - for (const [key, value] of Object.entries(tokens.color.nomgap)) { - lines.push(` ${key}: '${value}',`); - } - lines.push(' },', ''); - - // Spacing - lines.push(' // ── Spacing (8pt grid) ──────────────────────────────────────────────'); - lines.push(' spacing: {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Radius - lines.push(' // ── Border Radius ───────────────────────────────────────────────────'); - lines.push(' radius: {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Typography - lines.push(' // ── Typography ─────────────────────────────────────────────────────'); - lines.push(' typography: {'); - lines.push(' fontFamily: {'); - for (const key of Object.keys(tokens.typography.fontFamily)) { - // Use system fonts for React Native - const systemFont = key === 'display' ? 'System' : key === 'mono' ? 'Courier' : 'System'; - lines.push(` ${key}: '${systemFont}',`); - } - lines.push(' },'); - lines.push(' fontSize: {'); - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },'); - lines.push(' fontWeight: {'); - for (const [key, value] of Object.entries(tokens.typography.fontWeight)) { - lines.push(` ${key}: '${value}',`); - } - lines.push(' },'); - lines.push(' },', ''); - - // Icon sizes - lines.push(' // ── Icon Sizes ──────────────────────────────────────────────────────'); - lines.push(' icon: {'); - for (const [key, value] of Object.entries(tokens.icon)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Z-index (for RN zIndex style prop) - lines.push(' // ── Z-Index Layers ───────────────────────────────────────────────────'); - lines.push(' zIndex: {'); - for (const [key, value] of Object.entries(tokens.zIndex)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Opacity - lines.push(' // ── Opacity ─────────────────────────────────────────────────────────'); - lines.push(' opacity: {'); - for (const [key, value] of Object.entries(tokens.opacity)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - // Motion (duration in ms) - lines.push(' // ── Motion ────────────────────────────────────────────────────────────'); - lines.push(' motion: {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` ${key}: ${value},`); - } - lines.push(' },', ''); - - lines.push('} as const;', ''); - lines.push(''); - lines.push('export type Tokens = typeof tokens;', ''); - - return lines.join('\n'); -} - -// ── Write ────────────────────────────────────────────────────────── -writeFileSync(resolve(outDir, 'tokens.ts'), generateReactNative()); -// eslint-disable-next-line no-console -console.log('Generated React Native tokens in generated/react-native/'); diff --git a/vendor/bytelyst/design-tokens/scripts/generate.ts b/vendor/bytelyst/design-tokens/scripts/generate.ts deleted file mode 100644 index 3675d12..0000000 --- a/vendor/bytelyst/design-tokens/scripts/generate.ts +++ /dev/null @@ -1,750 +0,0 @@ -/** - * Token generator — reads bytelyst.tokens.json, outputs 4 platform formats: - * 1. CSS custom properties (tokens.css) - * 2. TypeScript constants (tokens.ts) - * 3. Kotlin object (MindLystTokens.kt) — for KMP shared module - * 4. Swift structs (MindLystTheme.swift) — for iOS SwiftUI - * - * Output conventions match the hand-written originals in learning_multimodal_memory_agents: - * - Kotlin: SCREAMING_SNAKE_CASE, Palette/Dark/Light/BrainGradient/Typography/Motion/Layout - * - Swift: Color(hex: UInt), dark/light prefixes, Gradient(colors:), MindLystMotion, Color ext - * - CSS: [data-theme], --ml-fs-*, --ml-elevation-*, --ml-motion-* - * - * Usage: tsx scripts/generate.ts - */ - -/* eslint-disable no-console -- This generator is a CLI; console output confirms generated artifacts. */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); -const outDir = resolve(__dirname, '../generated'); - -mkdirSync(outDir, { recursive: true }); - -const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); - -// ── Product CSS mapping ───────────────────────────────────────────── -const PRODUCT_CSS_MAP: Record = { - mindlyst: { prefix: 'ml', colorsKey: 'brain' }, - chronomind: { prefix: 'cm', colorsKey: 'chronomind' }, - jarvisjr: { prefix: 'jj', colorsKey: 'jarvisjr' }, - nomgap: { prefix: 'ng', colorsKey: 'nomgap' }, - actiontrail: { prefix: 'at', colorsKey: 'actiontrail' }, - flowmonk: { prefix: 'fm', colorsKey: 'flowmonk' }, - notelett: { prefix: 'nl', colorsKey: 'notelett' }, - localmemgpt: { prefix: 'lmg', colorsKey: 'localmemgpt' }, - localllmlab: { prefix: 'llm', colorsKey: 'localllmlab' }, - lysnrai: { prefix: 'lys', colorsKey: 'lysnrai' }, - peakpulse: { prefix: 'pp', colorsKey: 'peakpulse' }, -}; - -// ── Helpers ────────────────────────────────────────────────────────── - -function camelToKebab(str: string): string { - return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); -} - -function camelToScreamingSnake(str: string): string { - return str.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(); -} - -function hexToUInt(hex: string): string { - return `0x${hex.replace('#', '').toUpperCase()}`; -} - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - -// ── 1. CSS ─────────────────────────────────────────────────────────── -function generateCSS(): string { - const lines: string[] = [ - '/* Auto-generated from bytelyst.tokens.json — do not edit manually */', - '', - ':root,', - '[data-theme="dark"] {', - ]; - - // Semantic colors (dark theme as default) - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - lines.push(` --ml-${camelToKebab(key)}: ${value};`); - } - lines.push(''); - - // Typography - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - // Swap single quotes → double quotes for CSS - const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; - lines.push(` --ml-font-${key}: ${cssVal};`); - } - lines.push(''); - - // Font sizes (--ml-fs-* to match existing convention) - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` --ml-fs-${key}: ${value}px;`); - } - lines.push(''); - - // Spacing - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` --ml-space-${key}: ${value === 0 ? '0' : `${value}px`};`); - } - lines.push(''); - - // Radius - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` --ml-radius-${key}: ${value}px;`); - } - lines.push(''); - - // Elevation (--ml-elevation-* to match existing) - for (const [key, value] of Object.entries(tokens.elevation)) { - if (key === 'none') continue; - lines.push(` --ml-elevation-${key}: ${value};`); - } - lines.push(''); - - // Motion - for (const [key, value] of Object.entries(tokens.motion.duration)) { - if (key === 'instant') continue; // not used in CSS - lines.push(` --ml-motion-${key}: ${value}ms;`); - } - lines.push(` --ml-easing-standard: ${tokens.motion.easing.standard};`); - - lines.push('}', ''); - - // Light theme overrides - lines.push('[data-theme="light"] {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - // Only emit overrides where light differs from dark - const darkVal = tokens.color.semantic.dark[key]; - if (value !== darkVal) { - lines.push(` --ml-${camelToKebab(key)}: ${value};`); - } - } - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 2. TypeScript ──────────────────────────────────────────────────── -function generateTS(): string { - return [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - '', - `export const tokens = ${JSON.stringify(tokens, null, 2)} as const;`, - '', - 'export type Tokens = typeof tokens;', - '', - ].join('\n'); -} - -// ── 3. Kotlin ──────────────────────────────────────────────────────── -function generateKotlin(): string { - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - 'package com.mindlyst.shared.theme', - '', - '/**', - ' * Cross-platform design tokens from bytelyst.tokens.json.', - ' * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI).', - ' */', - 'object MindLystTokens {', - '', - ]; - - // ── Palette - lines.push(' // ── Color Palette ────────────────────────────────────────────────'); - lines.push(' object Palette {'); - for (const [key, value] of Object.entries(tokens.color.palette.neutral)) { - lines.push( - ` const val NEUTRAL_${key} = 0xFF${(value as string).replace('#', '').toUpperCase()}` - ); - } - lines.push(''); - for (const [key, value] of Object.entries(tokens.color.palette.brand)) { - lines.push( - ` const val ${key.toUpperCase()} = 0xFF${(value as string).replace('#', '').toUpperCase()}` - ); - } - lines.push(' }', ''); - - // ── Dark semantic - lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); - lines.push(' object Dark {'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // ── Light semantic - lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); - lines.push(' object Light {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // ── Brain gradients - lines.push(' // ── Brain Identity Gradients ─────────────────────────────────────'); - lines.push(' data class BrainGradient(val from: Long, val to: Long)'); - lines.push(''); - for (const [name, grad] of Object.entries(tokens.color.brain) as [ - string, - { from: string; to: string }, - ][]) { - lines.push( - ` val BRAIN_${name.toUpperCase()} = BrainGradient(from = 0xFF${grad.from.replace('#', '').toUpperCase()}, to = 0xFF${grad.to.replace('#', '').toUpperCase()})` - ); - } - lines.push(''); - - // ── Spacing - lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); - lines.push(' object Spacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` const val X${key} = ${value}`); - } - lines.push(' }', ''); - - // ── Radius - lines.push(' // ── Radius ───────────────────────────────────────────────────────'); - lines.push(' object Radius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // ── Typography - lines.push(' // ── Typography ───────────────────────────────────────────────────'); - lines.push(' object Typography {'); - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - // Extract just the primary font name (first in the list) - const fontName = - typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; - lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); - } - lines.push(''); - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - const sizeKey = key.toUpperCase().replace('-', ''); - lines.push(` const val SIZE_${sizeKey} = ${value}`); - } - lines.push(' }', ''); - - // ── Motion - lines.push(' // ── Motion ───────────────────────────────────────────────────────'); - lines.push(' object Motion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // ── Layout - lines.push(' // ── Layout ───────────────────────────────────────────────────────'); - lines.push(' object Layout {'); - lines.push(` const val TOUCH_TARGET_MIN = ${tokens.layout.touchTargetMin}`); - lines.push(` const val MOBILE_GUTTER = ${tokens.layout.mobileGutter}`); - lines.push(` const val MAX_WIDTH = ${tokens.layout.maxContentWidth}`); - lines.push(' }'); - - lines.push('}', ''); - return lines.join('\n'); -} - -// ── 4. Swift ───────────────────────────────────────────────────────── -function generateSwift(): string { - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually', - 'import SwiftUI', - '', - '// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens)', - '// These values mirror MindLystTokens.kt exactly.', - '', - 'struct MindLystColors {', - ]; - - // Dark colors - lines.push(' // Dark'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let dark${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); - } else if (typeof value === 'string' && value.startsWith('rgba')) { - // border/overlay → special handling - if (key === 'borderDefault') { - lines.push(' static let darkBorder = Color.white.opacity(0.12)'); - } - } - } - lines.push(''); - - // Light colors - lines.push(' // Light'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - if (value === '#FFFFFF') { - lines.push(` static let light${capitalize(key)} = Color.white`); - } else { - lines.push(` static let light${capitalize(key)} = Color(hex: ${hexToUInt(value)})`); - } - } - } - lines.push(''); - - // Brain gradients - lines.push(' // Brain Gradients'); - for (const [name, grad] of Object.entries(tokens.color.brain) as [ - string, - { from: string; to: string }, - ][]) { - lines.push( - ` static let brain${capitalize(name)} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` - ); - } - lines.push('}', ''); - - // Spacing - lines.push('struct MindLystSpacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - const pad = key.length === 1 ? ' ' : ''; - lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); - } - lines.push('}', ''); - - // Radius - lines.push('struct MindLystRadius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - const pad = key.length < 4 ? ' '.repeat(4 - key.length) : ''; - lines.push(` static let ${key}:${pad} CGFloat = ${value}`); - } - lines.push('}', ''); - - // Motion (durations in seconds) - lines.push('struct MindLystMotion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - const seconds = (value as number) / 1000; - const pad = key.length < 7 ? ' '.repeat(7 - key.length) : ''; - lines.push(` static let ${key}:${pad} Double = ${seconds.toFixed(2)}`); - } - lines.push('}', ''); - - // Color hex extension - lines.push('// MARK: - Color Hex Extension'); - lines.push(''); - lines.push('extension Color {'); - lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); - lines.push(' self.init('); - lines.push(' .sRGB,'); - lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); - lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); - lines.push(' blue: Double(hex & 0xFF) / 255.0,'); - lines.push(' opacity: alpha'); - lines.push(' )'); - lines.push(' }'); - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 5. Per-product CSS ────────────────────────────────────────────── -function generateProductCSS(productId: string, prefix: string, colorsKey: string): string { - const productColors = tokens.color[colorsKey]; - if (!productColors) return `/* No palette found for ${productId} (color.${colorsKey}) */\n`; - - const lines: string[] = [ - `/* Auto-generated ${productId} tokens from bytelyst.tokens.json — do not edit manually */`, - '', - ':root {', - ]; - - // Semantic colors (dark as default) — shared across all products - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } - lines.push(''); - - // Product-specific colors - lines.push(` /* ${productId} product colors */`); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string') { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } else if (typeof value === 'object' && value !== null && 'from' in value && 'to' in value) { - const grad = value as unknown as { from: string; to: string }; - lines.push(` --${prefix}-${camelToKebab(key)}-from: ${grad.from};`); - lines.push(` --${prefix}-${camelToKebab(key)}-to: ${grad.to};`); - } - } - lines.push(''); - - // Typography - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value; - lines.push(` --${prefix}-font-${key}: ${cssVal};`); - } - lines.push(''); - - // Font sizes - for (const [key, value] of Object.entries(tokens.typography.fontSize)) { - lines.push(` --${prefix}-fs-${key}: ${value}px;`); - } - lines.push(''); - - // Spacing - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` --${prefix}-space-${key}: ${value === 0 ? '0' : `${value}px`};`); - } - lines.push(''); - - // Radius - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` --${prefix}-radius-${key}: ${value}px;`); - } - lines.push(''); - - // Elevation - for (const [key, value] of Object.entries(tokens.elevation)) { - if (key === 'none') continue; - lines.push(` --${prefix}-elevation-${key}: ${value};`); - } - lines.push(''); - - // Motion - for (const [key, value] of Object.entries(tokens.motion.duration)) { - if (key === 'instant') continue; - lines.push(` --${prefix}-motion-${key}: ${value}ms;`); - } - lines.push(` --${prefix}-easing-standard: ${tokens.motion.easing.standard};`); - - lines.push('}', ''); - - // Light theme overrides - lines.push('[data-theme="light"] {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - const darkVal = (tokens.color.semantic.dark as Record)[key]; - if (value !== darkVal) { - lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`); - } - } - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── Product native mapping (products with iOS/Android apps) ───────── -interface ProductNativeConfig { - colorsKey: string; - swiftEnum: string; // e.g. 'PeakPulseColors' - kotlinObject: string; // e.g. 'PeakPulseTokens' - kotlinPackage: string; // e.g. 'com.peakpulse.theme' - swiftFile: string; // e.g. 'PeakPulseTheme.swift' - kotlinFile: string; // e.g. 'PeakPulseTokens.kt' -} - -const PRODUCT_NATIVE_MAP: Record = { - chronomind: { - colorsKey: 'chronomind', - swiftEnum: 'CMColors', - kotlinObject: 'ChronoMindTokens', - kotlinPackage: 'com.chronomind.app.theme', - swiftFile: 'ChronoMindTheme.generated.swift', - kotlinFile: 'ChronoMindTokens.generated.kt', - }, - jarvisjr: { - colorsKey: 'jarvisjr', - swiftEnum: 'JarvisJrColors', - kotlinObject: 'JarvisJrTokens', - kotlinPackage: 'com.jarvisjr.app.theme', - swiftFile: 'JarvisJrTheme.generated.swift', - kotlinFile: 'JarvisJrTokens.generated.kt', - }, - peakpulse: { - colorsKey: 'peakpulse', - swiftEnum: 'PeakPulseColors', - kotlinObject: 'PeakPulseTokens', - kotlinPackage: 'com.peakpulse.theme', - swiftFile: 'PeakPulseTheme.generated.swift', - kotlinFile: 'PeakPulseTokens.generated.kt', - }, - lysnrai: { - colorsKey: 'lysnrai', - swiftEnum: 'LysnrAIColors', - kotlinObject: 'LysnrAITokens', - kotlinPackage: 'com.saravana.lysnrai.theme', - swiftFile: 'LysnrAITheme.generated.swift', - kotlinFile: 'LysnrAITokens.generated.kt', - }, - nomgap: { - colorsKey: 'nomgap', - swiftEnum: 'NomGapColors', - kotlinObject: 'NomGapTokens', - kotlinPackage: 'com.nomgap.theme', - swiftFile: 'NomGapTheme.generated.swift', - kotlinFile: 'NomGapTokens.generated.kt', - }, - actiontrail: { - colorsKey: 'actiontrail', - swiftEnum: 'ActionTrailColors', - kotlinObject: 'ActionTrailTokens', - kotlinPackage: 'com.actiontrail.theme', - swiftFile: 'ActionTrailTheme.generated.swift', - kotlinFile: 'ActionTrailTokens.generated.kt', - }, - flowmonk: { - colorsKey: 'flowmonk', - swiftEnum: 'FlowMonkColors', - kotlinObject: 'FlowMonkTokens', - kotlinPackage: 'com.flowmonk.theme', - swiftFile: 'FlowMonkTheme.generated.swift', - kotlinFile: 'FlowMonkTokens.generated.kt', - }, - notelett: { - colorsKey: 'notelett', - swiftEnum: 'NoteLettColors', - kotlinObject: 'NoteLettTokens', - kotlinPackage: 'com.notelett.theme', - swiftFile: 'NoteLettTheme.generated.swift', - kotlinFile: 'NoteLettTokens.generated.kt', - }, - localmemgpt: { - colorsKey: 'localmemgpt', - swiftEnum: 'LocalMemGPTColors', - kotlinObject: 'LocalMemGPTTokens', - kotlinPackage: 'com.localmemgpt.theme', - swiftFile: 'LocalMemGPTTheme.generated.swift', - kotlinFile: 'LocalMemGPTTokens.generated.kt', - }, - localllmlab: { - colorsKey: 'localllmlab', - swiftEnum: 'LocalLLMLabColors', - kotlinObject: 'LocalLLMLabTokens', - kotlinPackage: 'com.localllmlab.theme', - swiftFile: 'LocalLLMLabTheme.generated.swift', - kotlinFile: 'LocalLLMLabTokens.generated.kt', - }, -}; - -// ── 6. Per-product Swift ───────────────────────────────────────────── -function generateProductSwift(productId: string, config: ProductNativeConfig): string { - const productColors = tokens.color[config.colorsKey]; - const lines: string[] = [ - `// Auto-generated from bytelyst.tokens.json — do not edit manually.`, - `// Product: ${productId}`, - `// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts`, - '', - 'import SwiftUI', - '', - `enum ${config.swiftEnum} {`, - ]; - - // Semantic dark colors - lines.push(' // MARK: - Semantic (Dark Theme)'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } - } - lines.push(''); - - // Product-specific colors - if (productColors) { - lines.push(` // MARK: - ${capitalize(productId)} Product Colors`); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } else if (typeof value === 'object' && value !== null && 'from' in value) { - const grad = value as { from: string; to: string }; - lines.push( - ` static let ${key} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])` - ); - } - } - lines.push(''); - } - - lines.push('}', ''); - - // Semantic light colors - lines.push(`enum ${config.swiftEnum}Light {`); - lines.push(' // MARK: - Semantic (Light Theme)'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`); - } - } - lines.push('}', ''); - - // Spacing - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Spacing {`); - for (const [key, value] of Object.entries(tokens.spacing)) { - const pad = key.length === 1 ? ' ' : ''; - lines.push(` static let x${key}: ${pad}CGFloat = ${value}`); - } - lines.push('}', ''); - - // Radius - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Radius {`); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` static let ${key}: CGFloat = ${value}`); - } - lines.push('}', ''); - - // Motion - lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Motion {`); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - const seconds = (value as number) / 1000; - lines.push(` static let ${key}: Double = ${seconds.toFixed(2)}`); - } - lines.push('}', ''); - - // Color hex extension (only include if not already in project) - lines.push('// MARK: - Color Hex Extension (import if not already defined)'); - lines.push(''); - lines.push('extension Color {'); - lines.push(' init(hex: UInt, alpha: Double = 1.0) {'); - lines.push(' self.init('); - lines.push(' .sRGB,'); - lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,'); - lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,'); - lines.push(' blue: Double(hex & 0xFF) / 255.0,'); - lines.push(' opacity: alpha'); - lines.push(' )'); - lines.push(' }'); - lines.push('}', ''); - - return lines.join('\n'); -} - -// ── 7. Per-product Kotlin ──────────────────────────────────────────── -function generateProductKotlin(productId: string, config: ProductNativeConfig): string { - const productColors = tokens.color[config.colorsKey]; - const lines: string[] = [ - '// Auto-generated from bytelyst.tokens.json — do not edit manually.', - `// Product: ${productId}`, - '// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts', - `package ${config.kotlinPackage}`, - '', - `object ${config.kotlinObject} {`, - '', - ]; - - // Semantic dark - lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────'); - lines.push(' object Dark {'); - for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // Semantic light - lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────'); - lines.push(' object Light {'); - for (const [key, value] of Object.entries(tokens.color.semantic.light)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - - // Product-specific colors - if (productColors) { - lines.push(` // ── ${capitalize(productId)} Product Colors ───────────────────────────────`); - lines.push(' object Product {'); - for (const [key, value] of Object.entries(productColors)) { - if (typeof value === 'string' && value.startsWith('#')) { - lines.push( - ` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}` - ); - } else if (typeof value === 'object' && value !== null && 'from' in value) { - const grad = value as { from: string; to: string }; - lines.push( - ` const val ${camelToScreamingSnake(key)}_FROM = 0xFF${grad.from.replace('#', '').toUpperCase()}` - ); - lines.push( - ` const val ${camelToScreamingSnake(key)}_TO = 0xFF${grad.to.replace('#', '').toUpperCase()}` - ); - } - } - lines.push(' }', ''); - } - - // Spacing - lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────'); - lines.push(' object Spacing {'); - for (const [key, value] of Object.entries(tokens.spacing)) { - lines.push(` const val X${key} = ${value}`); - } - lines.push(' }', ''); - - // Radius - lines.push(' // ── Radius ───────────────────────────────────────────────────────'); - lines.push(' object Radius {'); - for (const [key, value] of Object.entries(tokens.radius)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }', ''); - - // Typography - lines.push(' // ── Typography ───────────────────────────────────────────────────'); - lines.push(' object Typography {'); - for (const [key, value] of Object.entries(tokens.typography.fontFamily)) { - const fontName = - typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value; - lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`); - } - lines.push(' }', ''); - - // Motion - lines.push(' // ── Motion ───────────────────────────────────────────────────────'); - lines.push(' object Motion {'); - for (const [key, value] of Object.entries(tokens.motion.duration)) { - lines.push(` const val ${key.toUpperCase()} = ${value}`); - } - lines.push(' }'); - - lines.push('}', ''); - return lines.join('\n'); -} - -// ── Write all ──────────────────────────────────────────────────────── -// Shared semantic tokens (backward compatible) -writeFileSync(resolve(outDir, 'tokens.css'), generateCSS()); -writeFileSync(resolve(outDir, 'tokens.ts'), generateTS()); -writeFileSync(resolve(outDir, 'MindLystTokens.kt'), generateKotlin()); -writeFileSync(resolve(outDir, 'MindLystTheme.swift'), generateSwift()); - -// Per-product CSS -for (const [productId, config] of Object.entries(PRODUCT_CSS_MAP)) { - const css = generateProductCSS(productId, config.prefix, config.colorsKey); - writeFileSync(resolve(outDir, `${productId}.css`), css); -} - -// Per-product Swift + Kotlin -const nativeDir = resolve(outDir, 'native'); -mkdirSync(nativeDir, { recursive: true }); -for (const [productId, config] of Object.entries(PRODUCT_NATIVE_MAP)) { - const swift = generateProductSwift(productId, config); - writeFileSync(resolve(nativeDir, config.swiftFile), swift); - const kotlin = generateProductKotlin(productId, config); - writeFileSync(resolve(nativeDir, config.kotlinFile), kotlin); -} - -console.log( - `Generated 4 shared + ${Object.keys(PRODUCT_CSS_MAP).length} product CSS + ${Object.keys(PRODUCT_NATIVE_MAP).length * 2} native token files in generated/` -); diff --git a/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs b/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs deleted file mode 100755 index 447852e..0000000 --- a/vendor/bytelyst/design-tokens/scripts/token-coverage.cjs +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node -/** - * Token coverage report — analyzes how well a product uses design tokens - * - * Usage: node scripts/token-coverage.js - */ - -const { readFileSync, readdirSync, statSync } = require('fs'); -const { join, resolve } = require('path'); - -const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; -const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; - -const TOKEN_PATTERNS = { - web: { - cssVar: /--ml-[a-z-]+/g, - tokensImport: /@bytelyst\/design-tokens/, - }, - ios: { - mindLystColors: /MindLystColors\./g, - colorExtension: /Color\(hex:/g, - }, - kmp: { - mindLystTokens: /MindLystTokens\./g, - }, -}; - -function findFiles(dir, files = []) { - try { - const items = readdirSync(dir); - for (const item of items) { - const fullPath = join(dir, item); - if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; - - const stat = statSync(fullPath); - if (stat.isDirectory()) { - findFiles(fullPath, files); - } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { - files.push(fullPath); - } - } - } catch { - // Directory might not exist - } - return files; -} - -function detectPlatform(files) { - const hasSwift = files.some(f => f.endsWith('.swift')); - const hasKotlin = files.some(f => f.endsWith('.kt')); - const hasTSX = files.some(f => f.endsWith('.tsx')); - - if (hasSwift) return 'ios'; - if (hasKotlin) return 'kmp'; - if (hasTSX) return 'web'; - return 'unknown'; -} - -function analyzeCoverage(files, platform) { - let tokenUsages = 0; - let hardcodedColors = 0; - let filesUsingTokens = 0; - let filesWithHardcoded = 0; - - const patterns = TOKEN_PATTERNS[platform] || {}; - - for (const file of files) { - const content = readFileSync(file, 'utf-8'); - let hasTokens = false; - let hasHardcoded = false; - - // Check token usage - if (patterns.cssVar) { - const matches = content.match(patterns.cssVar); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - if (patterns.mindLystColors) { - const matches = content.match(patterns.mindLystColors); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - if (patterns.mindLystTokens) { - const matches = content.match(patterns.mindLystTokens); - if (matches) { - tokenUsages += matches.length; - hasTokens = true; - } - } - - // Check hardcoded colors - const colorMatches = content.match(/#[0-9A-Fa-f]{6}\b/g); - if (colorMatches) { - // Filter out likely non-color hex (like #FFFFFF in different contexts) - const likelyColors = colorMatches.filter(c => { - const hex = c.slice(1); - // Skip pure grays that might be intentional - if (hex[0] === hex[2] && hex[2] === hex[4]) return false; - return true; - }); - - if (likelyColors.length > 0) { - hardcodedColors += likelyColors.length; - hasHardcoded = true; - } - } - - if (hasTokens) filesUsingTokens++; - if (hasHardcoded) filesWithHardcoded++; - } - - return { - tokenUsages, - hardcodedColors, - filesUsingTokens, - filesWithHardcoded, - totalFiles: files.length, - }; -} - -function main() { - const targetPath = process.argv[2]; - if (!targetPath) { - console.log('Usage: node scripts/token-coverage.js '); - console.log(''); - console.log('Examples:'); - console.log(' node scripts/token-coverage.js ../../mindlyst-native/iosApp'); - console.log(' node scripts/token-coverage.js ../../learning_ai_clock/web/src'); - process.exit(1); - } - - const absolutePath = resolve(targetPath); - console.log(`📊 Analyzing token coverage for ${absolutePath}\n`); - - const files = findFiles(absolutePath); - const platform = detectPlatform(files); - - console.log(`Detected platform: ${platform}`); - console.log(`Total files: ${files.length}\n`); - - if (files.length === 0) { - console.log('❌ No source files found'); - process.exit(1); - } - - const coverage = analyzeCoverage(files, platform); - - console.log(`${'='.repeat(50)}`); - console.log('📈 Coverage Report'); - console.log(`${'='.repeat(50)}`); - console.log( - `Files using tokens: ${coverage.filesUsingTokens}/${coverage.totalFiles} (${((coverage.filesUsingTokens / coverage.totalFiles) * 100).toFixed(1)}%)` - ); - console.log( - `Files with hardcoded: ${coverage.filesWithHardcoded}/${coverage.totalFiles} (${((coverage.filesWithHardcoded / coverage.totalFiles) * 100).toFixed(1)}%)` - ); - console.log(`Token usages: ${coverage.tokenUsages}`); - console.log(`Hardcoded colors: ${coverage.hardcodedColors}`); - console.log(`${'='.repeat(50)}`); - - const tokenRatio = coverage.tokenUsages + coverage.hardcodedColors; - const tokenPercentage = - tokenRatio > 0 ? ((coverage.tokenUsages / tokenRatio) * 100).toFixed(1) : 'N/A'; - - console.log(`\n🎯 Token Adoption: ${tokenPercentage}%`); - - if (coverage.hardcodedColors > 0) { - console.log(`\n⚠️ Found ${coverage.hardcodedColors} hardcoded colors.`); - console.log(' Run validate-tokens.js for details.'); - } - - if (coverage.filesUsingTokens === 0) { - console.log('\n❌ No token usage detected. Product needs token integration.'); - process.exit(1); - } - - console.log('\n✅ Coverage analysis complete.'); -} - -main(); diff --git a/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs b/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs deleted file mode 100755 index 13e22b9..0000000 --- a/vendor/bytelyst/design-tokens/scripts/validate-tokens.cjs +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env node -/** - * Token validation script — checks for hardcoded colors in source files - * and reports token coverage per product. - * - * Usage: node scripts/validate-tokens.js [product-path] - */ - -const { readFileSync, readdirSync, statSync } = require('fs'); -const { join, resolve } = require('path'); - -const HARD_COLOR_REGEX = /#[0-9A-Fa-f]{3,8}\b|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)/g; -const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; -const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; - -function findFiles(dir, files = []) { - try { - const items = readdirSync(dir); - for (const item of items) { - const fullPath = join(dir, item); - if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; - - const stat = statSync(fullPath); - if (stat.isDirectory()) { - findFiles(fullPath, files); - } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { - files.push(fullPath); - } - } - } catch { - // Directory might not exist or be accessible - } - return files; -} - -function analyzeFile(filePath) { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - const issues = []; - - lines.forEach((line, index) => { - // Skip comments - if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) - return; - - const matches = line.match(HARD_COLOR_REGEX); - if (matches) { - // Filter out legitimate uses (like transparency values) - const suspicious = matches.filter(m => { - if (m.startsWith('#') && (m.length === 9 || m.length === 5)) return false; // Skip alpha hex - if (m.includes('0.0') || m.includes('1.0')) return false; // Skip clear/opaque - // Skip CSS-variable token usage (already tokenized) - if ((m.startsWith('hsl(') || m.startsWith('rgb(') || m.startsWith('rgba(')) && m.includes('var(--')) - return false; - return true; - }); - - if (suspicious.length > 0) { - issues.push({ - line: index + 1, - colors: suspicious, - content: line.trim().slice(0, 80), - }); - } - } - }); - - return issues; -} - -function main() { - const targetPath = process.argv[2] || '.'; - const absolutePath = resolve(targetPath); - - console.log(`🔍 Scanning ${absolutePath} for hardcoded colors...\n`); - - const files = findFiles(absolutePath); - let totalIssues = 0; - let filesWithIssues = 0; - - for (const file of files) { - const issues = analyzeFile(file); - if (issues.length > 0) { - filesWithIssues++; - totalIssues += issues.length; - const relativePath = file.replace(absolutePath, '').slice(1); - console.log(`\n📄 ${relativePath}`); - issues.forEach(issue => { - console.log(` Line ${issue.line}: ${issue.colors.join(', ')}`); - console.log(` ${issue.content}`); - }); - } - } - - console.log(`\n${'='.repeat(60)}`); - console.log(`📊 Summary:`); - console.log(` Files scanned: ${files.length}`); - console.log(` Files with hardcoded colors: ${filesWithIssues}`); - console.log(` Total hardcoded colors found: ${totalIssues}`); - - if (totalIssues > 0) { - console.log(`\n⚠️ Consider replacing hardcoded colors with design tokens:`); - console.log(` Web: var(--ml-) from @bytelyst/design-tokens`); - console.log(` iOS: MindLystColors.dark / MindLystColors.light`); - console.log(` KMP: MindLystTokens.Dark. / MindLystTokens.Light.`); - process.exit(1); - } else { - console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`); - process.exit(0); - } -} - -main(); diff --git a/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts b/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts deleted file mode 100644 index 93c91f6..0000000 --- a/vendor/bytelyst/design-tokens/src/__tests__/tokens.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { loadTokens } from '../index.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const generatedDir = resolve(__dirname, '../../generated'); - -describe('loadTokens', () => { - it('returns a valid DesignTokens object', () => { - const tokens = loadTokens(); - expect(tokens).toBeDefined(); - expect(tokens.meta).toBeDefined(); - expect(tokens.meta.name).toBeTruthy(); - expect(tokens.meta.version).toBeTruthy(); - }); - - it('has color palette with expected keys', () => { - const tokens = loadTokens(); - expect(tokens.color).toBeDefined(); - expect(tokens.color.palette).toBeDefined(); - expect(tokens.color.semantic).toBeDefined(); - expect(tokens.color.semantic.dark).toBeDefined(); - expect(tokens.color.brain).toBeDefined(); - }); - - it('has typography with font families', () => { - const tokens = loadTokens(); - expect(tokens.typography.fontFamily).toBeDefined(); - expect(Object.keys(tokens.typography.fontFamily).length).toBeGreaterThan(0); - }); - - it('has spacing values on 4pt grid', () => { - const tokens = loadTokens(); - expect(tokens.spacing).toBeDefined(); - // All spacing values should be multiples of 4 - for (const [, value] of Object.entries(tokens.spacing)) { - expect(value % 4).toBe(0); - } - }); - - it('has radius values', () => { - const tokens = loadTokens(); - expect(tokens.radius).toBeDefined(); - expect(Object.keys(tokens.radius).length).toBeGreaterThan(0); - }); - - it('has motion durations and easings', () => { - const tokens = loadTokens(); - expect(tokens.motion).toBeDefined(); - expect(tokens.motion.duration).toBeDefined(); - expect(tokens.motion.easing).toBeDefined(); - }); - - it('caches after first load', () => { - const t1 = loadTokens(); - const t2 = loadTokens(); - expect(t1).toBe(t2); - }); -}); - -describe('generated files', () => { - it('tokens.css exists and contains --ml- properties', () => { - const cssPath = resolve(generatedDir, 'tokens.css'); - expect(existsSync(cssPath)).toBe(true); - const css = readFileSync(cssPath, 'utf-8'); - expect(css).toContain('--ml-'); - }); - - it('tokens.ts exists and exports token values', () => { - const tsPath = resolve(generatedDir, 'tokens.ts'); - expect(existsSync(tsPath)).toBe(true); - const ts = readFileSync(tsPath, 'utf-8'); - expect(ts).toContain('export'); - }); - - it('MindLystTokens.kt exists and contains object declaration', () => { - const ktPath = resolve(generatedDir, 'MindLystTokens.kt'); - expect(existsSync(ktPath)).toBe(true); - const kt = readFileSync(ktPath, 'utf-8'); - expect(kt).toContain('object MindLystTokens'); - }); - - it('MindLystTheme.swift exists and contains struct', () => { - const swiftPath = resolve(generatedDir, 'MindLystTheme.swift'); - expect(existsSync(swiftPath)).toBe(true); - const swift = readFileSync(swiftPath, 'utf-8'); - expect(swift).toContain('MindLyst'); - }); -}); diff --git a/vendor/bytelyst/design-tokens/src/index.ts b/vendor/bytelyst/design-tokens/src/index.ts deleted file mode 100644 index 0ee2faa..0000000 --- a/vendor/bytelyst/design-tokens/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Design tokens — programmatic access to the canonical token JSON. - * For generated platform files, see the `generated/` directory. - * For the canonical JSON source, see `tokens/bytelyst.tokens.json`. - */ - -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export interface DesignTokens { - meta: { name: string; version: string; updatedAt: string; scale: string }; - color: { - palette: Record>; - semantic: { - dark: Record; - light: Record; - }; - brain: Record; - }; - typography: { - fontFamily: Record; - fontWeight: Record; - fontSize: Record; - lineHeight: Record; - letterSpacing: Record; - }; - spacing: Record; - radius: Record; - elevation: Record; - motion: { - duration: Record; - easing: Record; - }; - breakpoints: Record; - layout: Record; -} - -let _cached: DesignTokens | null = null; - -export function loadTokens(): DesignTokens { - if (_cached) return _cached; - const tokenPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); - const raw = readFileSync(tokenPath, 'utf-8'); - _cached = JSON.parse(raw) as DesignTokens; - return _cached; -} diff --git a/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json b/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json deleted file mode 100644 index 007e896..0000000 --- a/vendor/bytelyst/design-tokens/tokens/bytelyst.tokens.json +++ /dev/null @@ -1,369 +0,0 @@ -{ - "meta": { - "name": "ByteLyst Design Tokens", - "version": "1.1.0", - "updatedAt": "2026-03-03", - "scale": "8pt" - }, - "color": { - "palette": { - "neutral": { - "0": "#FFFFFF", - "50": "#F6F8FC", - "100": "#EEF2FA", - "200": "#DCE4F2", - "300": "#BFCBDE", - "400": "#92A1BA", - "500": "#6C7C98", - "600": "#55637A", - "700": "#3B455A", - "800": "#1A2335", - "900": "#0E1320", - "950": "#06070A" - }, - "brand": { - "blue": "#5A8CFF", - "cyan": "#2EE6D6", - "coral": "#FF6E6E", - "gold": "#FFD166", - "mint": "#34D399", - "warning": "#F59E0B", - - "microsoftRed": "#F25022", - "microsoftGreen": "#7FBA00", - "microsoftBlue": "#00A4EF", - "microsoftYellow": "#FFB900", - - "googleBlue": "#4285F4", - "googleGreen": "#34A853", - "googleYellow": "#FBBC05", - "googleRed": "#EA4335" - } - }, - "semantic": { - "dark": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderDefault": "rgba(255,255,255,0.12)", - "borderStrong": "rgba(255,255,255,0.22)", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "rgba(90,140,255,0.45)", - "overlayScrim": "rgba(5,8,18,0.72)" - }, - "light": { - "bgCanvas": "#F6F8FC", - "bgElevated": "#EEF2FA", - "surfaceCard": "#FFFFFF", - "surfaceMuted": "#F3F5FA", - "borderDefault": "rgba(14,19,32,0.12)", - "borderStrong": "rgba(14,19,32,0.24)", - "textPrimary": "#0E1320", - "textSecondary": "#55637A", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#13956A", - "warning": "#B87504", - "danger": "#D24242", - "focusRing": "rgba(90,140,255,0.35)", - "overlayScrim": "rgba(10,13,23,0.5)" - } - }, - "brain": { - "work": { "from": "#5A8CFF", "to": "#2EE6D6" }, - "home": { "from": "#FF6E6E", "to": "#FFD166" }, - "money": { "from": "#34D399", "to": "#2EE6D6" }, - "health": { "from": "#2EE6D6", "to": "#9FE870" }, - "global": { "from": "#7D8FB4", "to": "#A5B1C7" } - }, - "jarvisjr": { - "accentPrimary": "#7C6BFF", - "accentSecondary": "#5AE6C8", - "accentVoice": "#FF6B8A", - "agentCoach": "#5A8CFF", - "agentLingua": "#FFB74D", - "agentSpark": "#E040FB", - "agentMentor": "#34D399", - "agentMirror": "#2EE6D6", - "agentOrator": "#FF9F43" - }, - "peakpulse": { - "activityHike": "#34D399", - "activitySki": "#5A8CFF", - "speedZoneSlow": "#34D399", - "speedZoneFast": "#FFD166", - "speedZoneDanger": "#FF6E6E", - "elevationGain": "#2EE6D6", - "elevationLoss": "#FF9F43", - "personalBest": "#FFD700", - "streakActive": "#34D399", - "streakBroken": "#FF6E6E" - }, - "chronomind": { - "urgencyCritical": "#FF6E6E", - "urgencyImportant": "#FFD166", - "urgencyStandard": "#5A8CFF", - "urgencyGentle": "#34D399", - "urgencyPassive": "#A5B1C7", - "focusMode": "#7C6BFF", - "pomodoroWork": "#34D399", - "pomodoroBreak": "#5A8CFF", - "cascadeWarning": "#FF9F43", - "timerComplete": "#34D399" - }, - "nomgap": { - "stageFed": "#FF9F43", - "stageEarlyFast": "#FECA57", - "stageFasted": "#48DBFB", - "stageKetosis": "#5A8CFF", - "stageDeepAutophagy": "#A66BFF", - "stageExtended": "#FFD700", - "autophagyMeter": "#5AE68C", - "hydrationReminder": "#48DBFB", - "electrolyteAlert": "#FF9F43", - "safetyWarning": "#FF6E6E" - }, - "lysnrai": { - "recordingActive": "#FF6E6E", - "recordingPaused": "#FFD166", - "processing": "#5A8CFF", - "transcribed": "#34D399", - "dictationMode": "#7C6BFF", - "commandMode": "#2EE6D6", - "hotkeyActive": "#FF6B8A" - }, - "flowmonk": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "zonework": "#5A8CFF", - "zonePersonal": "#5AE68C", - "zoneHealth": "#FF6B6B", - "zoneAdmin": "#FECA57", - "zoneLearning": "#A66BFF", - "urgentBadge": "#FF6E6E", - "scheduleEntry": "#5A8CFF", - "overflowWarning": "#F59E0B", - "recommendationInfo": "#5A8CFF", - "recommendationWarning": "#F59E0B", - "recommendationCritical": "#FF6E6E" - }, - "actiontrail": { - "bg": "#07111F", - "surface": "#0F1B2D", - "surfaceElevated": "#152338", - "border": "#24344D", - "text": "#EFF4FF", - "textMuted": "#A8B4C8", - "primary": "#5A8CFF", - "accent": "#5AE68C", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "riskLow": "#5AE68C", - "riskMedium": "#F59E0B", - "riskHigh": "#FF8C42", - "riskCritical": "#FF6E6E", - "statusPending": "#F59E0B", - "statusApplied": "#5AE68C", - "statusRejected": "#FF6E6E", - "statusReverted": "#A66BFF" - }, - "notelett": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "focusRing": "#5A8CFF", - "agentAction": "#A66BFF", - "draftNote": "#FFD166", - "linkedNote": "#2EE6D6", - "taskPending": "#F59E0B", - "taskComplete": "#34D399" - }, - "localmemgpt": { - "bgPrimary": "#0A0A0A", - "bgSecondary": "#141414", - "bgTertiary": "#1E1E1E", - "bgHover": "#252525", - "bgInput": "#1A1A1A", - "border": "#2A2A2A", - "textPrimary": "#F0F0F0", - "textSecondary": "#999999", - "textMuted": "#666666", - "accent": "#6366F1", - "accentHover": "#818CF8", - "success": "#22C55E", - "warning": "#F59E0B", - "error": "#EF4444" - }, - "localllmlab": { - "bgCanvas": "#06070A", - "bgElevated": "#0E1118", - "surfaceCard": "#121725", - "surfaceMuted": "#1A2335", - "borderSubtle": "#1E293B", - "borderDefault": "#2A3654", - "textPrimary": "#EFF4FF", - "textSecondary": "#A5B1C7", - "textTertiary": "#6C7C98", - "accentPrimary": "#5A8CFF", - "accentSecondary": "#2EE6D6", - "success": "#34D399", - "warning": "#F59E0B", - "danger": "#FF6E6E", - "purple": "#A78BFA" - } - }, - "typography": { - "fontFamily": { - "display": "'Space Grotesk', 'SF Pro Display', sans-serif", - "body": "'DM Sans', 'SF Pro Text', sans-serif", - "mono": "'IBM Plex Mono', 'SF Mono', monospace" - }, - "fontWeight": { - "regular": 400, - "medium": 500, - "semibold": 600, - "bold": 700 - }, - "fontSize": { - "xs": 12, - "sm": 14, - "md": 16, - "lg": 18, - "xl": 22, - "2xl": 28, - "3xl": 36 - }, - "lineHeight": { - "tight": 1.2, - "normal": 1.45, - "relaxed": 1.65 - }, - "letterSpacing": { - "tight": -0.02, - "normal": 0, - "wide": 0.02 - } - }, - "spacing": { - "0": 0, - "1": 4, - "2": 8, - "3": 12, - "4": 16, - "5": 20, - "6": 24, - "7": 28, - "8": 32, - "10": 40, - "12": 48, - "16": 64 - }, - "radius": { - "xs": 8, - "sm": 12, - "md": 16, - "lg": 20, - "xl": 24, - "pill": 999 - }, - "elevation": { - "none": "0 0 0 rgba(0,0,0,0)", - "sm": "0 4px 12px rgba(0,0,0,0.12)", - "md": "0 12px 28px rgba(0,0,0,0.18)", - "lg": "0 20px 48px rgba(0,0,0,0.24)" - }, - "motion": { - "duration": { - "instant": 70, - "fast": 140, - "base": 220, - "slow": 320 - }, - "easing": { - "standard": "cubic-bezier(0.2, 0.0, 0.2, 1)", - "decelerate": "cubic-bezier(0.0, 0.0, 0.2, 1)", - "accelerate": "cubic-bezier(0.4, 0.0, 1, 1)" - } - }, - "breakpoints": { - "mobile": 0, - "tablet": 768, - "desktop": 1200, - "wide": 1440 - }, - "layout": { - "maxContentWidth": 1280, - "mobileGutter": 16, - "tabletGutter": 24, - "desktopGutter": 32, - "touchTargetMin": 44 - }, - "zIndex": { - "hidden": -1, - "base": 0, - "dropdown": 100, - "sticky": 200, - "fixed": 300, - "overlay": 400, - "modal": 500, - "popover": 600, - "toast": 700, - "tooltip": 800 - }, - "icon": { - "xs": 12, - "sm": 16, - "md": 20, - "lg": 24, - "xl": 32, - "2xl": 48 - }, - "grid": { - "columns": 12, - "gutter": 24, - "maxWidth": 1200, - "breakpoints": { - "xs": 0, - "sm": 576, - "md": 768, - "lg": 992, - "xl": 1200, - "xxl": 1400 - } - }, - "opacity": { - "0": 0, - "10": 0.1, - "20": 0.2, - "30": 0.3, - "40": 0.4, - "50": 0.5, - "60": 0.6, - "70": 0.7, - "80": 0.8, - "90": 0.9, - "100": 1 - } -} diff --git a/vendor/bytelyst/design-tokens/tsconfig.json b/vendor/bytelyst/design-tokens/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/design-tokens/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/diagnostics-client/package.json b/vendor/bytelyst/diagnostics-client/package.json deleted file mode 100644 index 079dbd5..0000000 --- a/vendor/bytelyst/diagnostics-client/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@bytelyst/diagnostics-client", - "version": "0.1.5", - "description": "TypeScript client for remote diagnostics and debug tracing", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "peerDependencies": { - "zod": "^3.22.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts b/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts deleted file mode 100644 index 4b6364c..0000000 --- a/vendor/bytelyst/diagnostics-client/src/__tests__/client.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { - DiagnosticsClient, - BreadcrumbTrail, - NetworkInterceptor, -} from '../index.js'; - -describe('DiagnosticsClient', () => { - const mockConfig = { - productId: 'test-app', - anonymousInstallId: 'install_123', - platform: 'web', - channel: 'web_app', - osFamily: 'macos', - appVersion: '1.0.0', - buildNumber: '100', - releaseChannel: 'beta', - serverUrl: 'https://api.test.com', - pollIntervalMs: 100, // Fast polling for tests - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - }; - - let fetchMock: ReturnType; - - beforeEach(() => { - DiagnosticsClient.reset(); - vi.clearAllMocks(); - - // Mock fetch to return empty session (no active debug session) - fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - headers: new Map(), - json: async () => null, // No active session - }); - globalThis.fetch = fetchMock; - }); - - afterEach(() => { - DiagnosticsClient.reset(); - vi.restoreAllMocks(); - }); - - describe('singleton', () => { - it('should create instance with getInstance', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - expect(client).toBeDefined(); - expect(DiagnosticsClient.isInitialized()).toBe(true); - }); - - it('should return same instance on subsequent calls', () => { - const client1 = DiagnosticsClient.getInstance(mockConfig); - const client2 = DiagnosticsClient.getInstance(); - expect(client1).toBe(client2); - }); - - it('should throw if getInstance called without config first', () => { - expect(() => DiagnosticsClient.getInstance()).toThrow( - 'must be initialized with config first' - ); - }); - - it('should reset instance', () => { - DiagnosticsClient.getInstance(mockConfig); - expect(DiagnosticsClient.isInitialized()).toBe(true); - DiagnosticsClient.reset(); - expect(DiagnosticsClient.isInitialized()).toBe(false); - }); - }); - - describe('lifecycle', () => { - it('should start and stop', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await client.start(); - // After start, state should be polling (no active session from mock) - expect(client.getState().type).toBe('polling'); - client.stop(); - expect(client.getState().type).toBe('idle'); - }); - - it('should warn if started twice', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await client.start(); - // First clear any calls from the first start - mockConfig.logger.warn.mockClear(); - // Second start should warn - await client.start(); - expect(mockConfig.logger.warn).toHaveBeenCalledWith( - '[diagnostics] Already started' - ); - }); - }); - - describe('session state', () => { - it('should report no active session initially', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - expect(client.isSessionActive()).toBe(false); - expect(client.getCurrentSession()).toBeNull(); - }); - }); - - describe('logging', () => { - it('should record log entries', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.log('info', 'Test message', { foo: 'bar' }); - // Logs are buffered, no immediate assertion - expect(client.getBreadcrumbs().length).toBeGreaterThan(0); - }); - - it('should add breadcrumb on log', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.log('warn', 'Warning message'); - const crumbs = client.getBreadcrumbs(); - expect(crumbs.some(c => c.message.includes('Warning'))).toBe(true); - }); - }); - - describe('tracing', () => { - it('should trace successful operation', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - const result = await client.trace('test-op', () => 'success'); - expect(result).toBe('success'); - }); - - it('should trace async operation', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - const result = await client.trace('async-op', async () => { - await new Promise(r => setTimeout(r, 10)); - return 42; - }); - expect(result).toBe(42); - }); - - it('should propagate errors', async () => { - const client = DiagnosticsClient.getInstance(mockConfig); - await expect( - client.trace('failing-op', () => { - throw new Error('test error'); - }) - ).rejects.toThrow('test error'); - }); - }); - - describe('breadcrumbs', () => { - it('should add breadcrumbs', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.breadcrumb('navigation', 'Page loaded', { path: '/' }); - const crumbs = client.getBreadcrumbs(); - expect(crumbs.length).toBe(1); - expect(crumbs[0].category).toBe('navigation'); - expect(crumbs[0].message).toBe('Page loaded'); - }); - - it('should include data in breadcrumbs', () => { - const client = DiagnosticsClient.getInstance(mockConfig); - client.breadcrumb('user', 'Clicked', { button: 'submit' }); - const crumbs = client.getBreadcrumbs(); - expect(crumbs[0].data).toEqual({ button: 'submit' }); - }); - }); -}); - -describe('BreadcrumbTrail', () => { - it('should add breadcrumbs', () => { - const trail = new BreadcrumbTrail(); - trail.add('test', 'message'); - expect(trail.size()).toBe(1); - }); - - it('should evict oldest when over limit', () => { - const trail = new BreadcrumbTrail({ maxSize: 3 }); - trail.add('a', '1'); - trail.add('b', '2'); - trail.add('c', '3'); - trail.add('d', '4'); // Should evict 'a' - expect(trail.size()).toBe(3); - const all = trail.getAll(); - expect(all[0].category).toBe('b'); - }); - - it('should get last N breadcrumbs', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.add('b', '2'); - trail.add('c', '3'); - const last2 = trail.getLast(2); - expect(last2.length).toBe(2); - expect(last2[0].category).toBe('b'); - }); - - it('should get most recent', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.add('b', '2'); - const recent = trail.getMostRecent(); - expect(recent?.category).toBe('b'); - }); - - it('should clear all', () => { - const trail = new BreadcrumbTrail(); - trail.add('a', '1'); - trail.clear(); - expect(trail.size()).toBe(0); - }); -}); - -describe('NetworkInterceptor', () => { - it('should start and stop', () => { - const onRequest = vi.fn(); - const interceptor = new NetworkInterceptor(onRequest); - interceptor.start(); - expect(interceptor.isRunning()).toBe(true); - interceptor.stop(); - expect(interceptor.isRunning()).toBe(false); - }); - - it('should not capture when stopped', () => { - const onRequest = vi.fn(); - const interceptor = new NetworkInterceptor(onRequest); - expect(interceptor.isRunning()).toBe(false); - }); -}); diff --git a/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts b/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts deleted file mode 100644 index 4898498..0000000 --- a/vendor/bytelyst/diagnostics-client/src/breadcrumbs.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Breadcrumb trail — ring buffer for timeline navigation - * - * @module breadcrumbs - */ - -import type { Breadcrumb } from './types.js'; - -export interface BreadcrumbTrailOptions { - /** Maximum number of breadcrumbs to keep (default: 100) */ - maxSize?: number; -} - -/** - * Ring buffer for breadcrumbs with fixed max size - */ -export class BreadcrumbTrail { - private breadcrumbs: Breadcrumb[] = []; - private maxSize: number; - - constructor(options: BreadcrumbTrailOptions = {}) { - this.maxSize = options.maxSize ?? 100; - } - - /** - * Add a breadcrumb to the trail - */ - add(category: string, message: string, data?: Record): void { - const breadcrumb: Breadcrumb = { - timestamp: new Date().toISOString(), - category, - message, - data, - }; - - this.breadcrumbs.push(breadcrumb); - - // Evict oldest if over limit - if (this.breadcrumbs.length > this.maxSize) { - this.breadcrumbs.shift(); - } - } - - /** - * Get all breadcrumbs (oldest first) - */ - getAll(): Breadcrumb[] { - return [...this.breadcrumbs]; - } - - /** - * Get last N breadcrumbs - */ - getLast(n: number): Breadcrumb[] { - return this.breadcrumbs.slice(-n); - } - - /** - * Get most recent breadcrumb - */ - getMostRecent(): Breadcrumb | null { - return this.breadcrumbs[this.breadcrumbs.length - 1] ?? null; - } - - /** - * Clear all breadcrumbs - */ - clear(): void { - this.breadcrumbs = []; - } - - /** - * Get current size - */ - size(): number { - return this.breadcrumbs.length; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/client.ts b/vendor/bytelyst/diagnostics-client/src/client.ts deleted file mode 100644 index 0118e8b..0000000 --- a/vendor/bytelyst/diagnostics-client/src/client.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Main DiagnosticsClient — singleton for remote diagnostics collection - * - * @module client - */ - -import type { - DiagnosticsConfig, - DiagnosticsSession, - ClientState, - LogLevel, - TraceSpan, - LogEntry, - Breadcrumb, - NetworkRequest, - DeviceState, -} from './types.js'; -import { BreadcrumbTrail } from './breadcrumbs.js'; -import { NetworkInterceptor } from './network.js'; -import { collectDeviceState } from './device.js'; - -// DOM type declarations for ESLint -type ErrorEvent = { - message: string; - filename: string; - lineno: number; - colno: number; - error?: { stack?: string }; -}; - -export interface DiagnosticsClientOptions extends DiagnosticsConfig { - /** Custom logger */ - logger?: { - debug: (msg: string, meta?: Record) => void; - info: (msg: string, meta?: Record) => void; - warn: (msg: string, meta?: Record) => void; - error: (msg: string, meta?: Record) => void; - }; -} - -/** - * Diagnostics client for remote debug session collection - */ -export class DiagnosticsClient { - private static instance: DiagnosticsClient | null = null; - - private config: DiagnosticsClientOptions & { - pollIntervalMs: number; - maxBreadcrumbs: number; - captureConsole: boolean; - captureErrors: boolean; - captureNetwork: boolean; - networkExcludePatterns: RegExp[]; - logger: NonNullable; - }; - private state: ClientState = { type: 'idle' }; - private breadcrumbs: BreadcrumbTrail; - private networkInterceptor: NetworkInterceptor | null = null; - private pollTimer: ReturnType | null = null; - private logBuffer: LogEntry[] = []; - private traceBuffer: TraceSpan[] = []; - private networkBuffer: NetworkRequest[] = []; - private flushTimer: ReturnType | null = null; - private lastEtag: string | null = null; - - private constructor(config: DiagnosticsClientOptions) { - this.config = { - ...config, - pollIntervalMs: config.pollIntervalMs ?? 5000, - maxBreadcrumbs: config.maxBreadcrumbs ?? 100, - captureConsole: config.captureConsole ?? true, - captureErrors: config.captureErrors ?? true, - captureNetwork: config.captureNetwork ?? true, - networkExcludePatterns: config.networkExcludePatterns ?? [], - logger: config.logger ?? { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }, - }; - - this.breadcrumbs = new BreadcrumbTrail({ - maxSize: this.config.maxBreadcrumbs, - }); - } - - /** - * Get singleton instance - */ - static getInstance(config?: DiagnosticsClientOptions): DiagnosticsClient { - if (!DiagnosticsClient.instance) { - if (!config) { - throw new Error('DiagnosticsClient must be initialized with config first'); - } - DiagnosticsClient.instance = new DiagnosticsClient(config); - } - return DiagnosticsClient.instance; - } - - /** - * Check if client is initialized - */ - static isInitialized(): boolean { - return DiagnosticsClient.instance !== null; - } - - /** - * Reset singleton (for testing) - */ - static reset(): void { - DiagnosticsClient.instance?.stop(); - DiagnosticsClient.instance = null; - } - - /** - * Start polling for active debug sessions - */ - async start(): Promise { - if (this.state.type === 'polling' || this.state.type === 'active') { - this.config.logger.warn('[diagnostics] Already started'); - return; - } - - this.state = { type: 'polling', session: null }; - this.config.logger.info('[diagnostics] Starting diagnostics client'); - - // Initial poll - await this.pollForSession(); - - // Start polling timer - this.pollTimer = setInterval(() => { - this.pollForSession().catch(err => { - this.config.logger.error('[diagnostics] Poll error', { error: err.message }); - }); - }, this.config.pollIntervalMs); - - // Start auto-flush timer (every 30 seconds) - this.flushTimer = setInterval(() => { - this.flush().catch(err => { - this.config.logger.error('[diagnostics] Flush error', { error: err.message }); - }); - }, 30000); - - // Setup auto-capture if configured - if (this.config.captureNetwork) { - this.setupNetworkCapture(); - } - - if (this.config.captureConsole) { - this.setupConsoleCapture(); - } - - if (this.config.captureErrors) { - this.setupErrorCapture(); - } - - this.breadcrumbs.add('diagnostics', 'Client started'); - } - - /** - * Stop polling and cleanup - */ - stop(): void { - this.config.logger.info('[diagnostics] Stopping diagnostics client'); - - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - - if (this.flushTimer) { - clearInterval(this.flushTimer); - this.flushTimer = null; - } - - this.networkInterceptor?.stop(); - this.networkInterceptor = null; - - // Final flush - this.flush().catch(() => {}); - - this.state = { type: 'idle' }; - this.breadcrumbs.add('diagnostics', 'Client stopped'); - } - - /** - * Check if a debug session is currently active - */ - isSessionActive(): boolean { - return this.state.type === 'active'; - } - - /** - * Get current session if active - */ - getCurrentSession(): DiagnosticsSession | null { - return this.state.type === 'active' || this.state.type === 'polling' - ? (this.state as { session: DiagnosticsSession | null }).session - : null; - } - - /** - * Get current client state - */ - getState(): ClientState { - return this.state; - } - - /** - * Record a log entry - */ - log(level: LogLevel, message: string, context: Record = {}): void { - const entry: LogEntry = { - level, - message, - timestamp: new Date().toISOString(), - module: (context.module as string) ?? 'unknown', - context, - correlationId: context.correlationId as string, - }; - - this.logBuffer.push(entry); - this.breadcrumbs.add('log', `[${level.toUpperCase()}] ${message.slice(0, 100)}`, { level }); - - // Auto-flush on fatal - if (level === 'fatal') { - this.flush().catch(() => {}); - } - } - - /** - * Record a trace span (auto-instrumented) - */ - async trace(name: string, operation: () => Promise): Promise; - async trace(name: string, operation: () => T): Promise; - async trace(name: string, operation: () => T | Promise): Promise { - const span: TraceSpan = { - spanId: this.generateId(), - name, - kind: 'internal', - startTime: new Date().toISOString(), - attributes: {}, - status: 'unset', - }; - - this.breadcrumbs.add('trace', `Starting: ${name}`, { spanId: span.spanId }); - - try { - const result = await operation(); - span.endTime = new Date().toISOString(); - span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); - span.status = 'ok'; - this.traceBuffer.push(span); - this.breadcrumbs.add('trace', `Completed: ${name}`, { - spanId: span.spanId, - durationMs: span.durationMs, - }); - return result; - } catch (error) { - span.endTime = new Date().toISOString(); - span.durationMs = new Date(span.endTime).getTime() - new Date(span.startTime).getTime(); - span.status = 'error'; - span.statusMessage = error instanceof Error ? error.message : String(error); - this.traceBuffer.push(span); - this.breadcrumbs.add('trace', `Failed: ${name}`, { - spanId: span.spanId, - error: span.statusMessage, - }); - throw error; - } - } - - /** - * Add a manual breadcrumb - */ - breadcrumb(category: string, message: string, data?: Record): void { - this.breadcrumbs.add(category, message, data); - } - - /** - * Get all breadcrumbs - */ - getBreadcrumbs(): Breadcrumb[] { - return this.breadcrumbs.getAll(); - } - - /** - * Collect and return device state - */ - collectDeviceState(): DeviceState { - return collectDeviceState(); - } - - /** - * Poll server for active session config - */ - private async pollForSession(): Promise { - try { - const url = new URL('/api/diagnostics/config', this.config.serverUrl); - url.searchParams.set('productId', this.config.productId); - url.searchParams.set('installId', this.config.anonymousInstallId); - - const headers: Record = { - Accept: 'application/json', - }; - - if (this.lastEtag) { - headers['If-None-Match'] = this.lastEtag; - } - - const token = await this.getAuthToken(); - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(url.toString(), { headers }); - - if (response.status === 304) { - // No change - return; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - // Store ETag for caching - const etag = response.headers.get('ETag'); - if (etag) { - this.lastEtag = etag; - } - - const session: DiagnosticsSession | null = await response.json(); - - // Update state - if (session && session.status === 'active') { - if (this.state.type !== 'active') { - this.config.logger.info('[diagnostics] Session activated', { sessionId: session.id }); - this.breadcrumbs.add('diagnostics', 'Session activated', { sessionId: session.id }); - } - this.state = { type: 'active', session }; - } else { - if (this.state.type === 'active') { - this.config.logger.info('[diagnostics] Session ended'); - this.breadcrumbs.add('diagnostics', 'Session ended'); - } - this.state = { type: 'polling', session: null }; - } - } catch (error) { - this.config.logger.error('[diagnostics] Failed to poll for session', { - error: error instanceof Error ? error.message : String(error), - }); - this.state = { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)), - }; - } - } - - /** - * Flush buffered data to server - */ - private async flush(): Promise { - const session = this.getCurrentSession(); - if (!session) { - // No active session, clear buffers - this.logBuffer = []; - this.traceBuffer = []; - this.networkBuffer = []; - return; - } - - const sessionId = session.id; - - const logs = this.logBuffer.splice(0, 50); // Server max: 50 - const traces = this.traceBuffer.splice(0, 50); // Server max: 50 - const network = this.networkBuffer.splice(0, 50); - const crumbs = this.breadcrumbs.getAll(); - this.breadcrumbs.clear(); - - // Encode breadcrumbs + network captures as log entries so we can ingest - // without requiring additional server-side schemas/endpoints. - const synthesizedLogs = [] as LogEntry[]; - - for (const c of crumbs) { - synthesizedLogs.push({ - level: 'info', - message: `[breadcrumb] ${c.category}: ${c.message}`, - timestamp: c.timestamp, - module: 'diagnostics.breadcrumb', - context: c.data ?? {}, - }); - } - - for (const n of network) { - synthesizedLogs.push({ - level: n.error ? 'error' : 'info', - message: `[network] ${n.method} ${n.url} ${n.status ?? ''}`.trim(), - timestamp: n.startTime, - module: 'diagnostics.network', - context: { - requestHeaders: n.requestHeaders, - requestBody: n.requestBody, - status: n.status, - responseHeaders: n.responseHeaders, - responseBody: n.responseBody, - startTime: n.startTime, - endTime: n.endTime, - durationMs: n.durationMs, - error: n.error, - }, - }); - } - - const allLogs = [...logs, ...synthesizedLogs]; - - if (allLogs.length === 0 && traces.length === 0) { - return; - } - - const token = await this.getAuthToken(); - const headers: Record = { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - - try { - if (allLogs.length > 0) { - const url = new URL( - `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/logs`, - this.config.serverUrl - ); - const response = await fetch(url.toString(), { - method: 'POST', - headers, - body: JSON.stringify({ sessionId, logs: allLogs }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - } - - if (traces.length > 0) { - const url = new URL( - `/api/diagnostics/sessions/${encodeURIComponent(sessionId)}/traces`, - this.config.serverUrl - ); - const response = await fetch(url.toString(), { - method: 'POST', - headers, - body: JSON.stringify({ sessionId, traces }), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - } - - this.config.logger.debug('[diagnostics] Flushed batch', { - logs: allLogs.length, - traces: traces.length, - }); - } catch (error) { - this.config.logger.error('[diagnostics] Failed to flush batch', { - error: error instanceof Error ? error.message : String(error), - }); - - // Put items back in buffers for retry - if (logs.length > 0) this.logBuffer.unshift(...logs); - if (traces.length > 0) this.traceBuffer.unshift(...traces); - if (network.length > 0) this.networkBuffer.unshift(...network); - - // Breadcrumbs were converted; keep a small breadcrumb trail hint for later flush. - for (const c of crumbs.slice(-10)) { - this.breadcrumbs.add(c.category, c.message, c.data); - } - } - } - - /** - * Setup network capture - */ - private setupNetworkCapture(): void { - this.networkInterceptor = new NetworkInterceptor( - request => { - this.networkBuffer.push(request); - }, - { - excludePatterns: this.config.networkExcludePatterns, - } - ); - this.networkInterceptor.start(); - this.breadcrumbs.add('diagnostics', 'Network capture enabled'); - } - - /** - * Setup console capture - */ - /* eslint-disable no-console -- This method intentionally wraps console APIs to capture diagnostics, then forwards to the originals. */ - private setupConsoleCapture(): void { - const originalConsole = { - log: console.log.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - }; - - const capture = (level: LogLevel, args: unknown[]) => { - const message = args - .map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))) - .join(' '); - this.log(level, message, { module: 'console', source: 'captured' }); - }; - - console.log = (...args: unknown[]) => { - capture('debug', args); - originalConsole.log(...args); - }; - console.info = (...args: unknown[]) => { - capture('info', args); - originalConsole.info(...args); - }; - console.warn = (...args: unknown[]) => { - capture('warn', args); - originalConsole.warn(...args); - }; - console.error = (...args: unknown[]) => { - capture('error', args); - originalConsole.error(...args); - }; - - this.breadcrumbs.add('diagnostics', 'Console capture enabled'); - } - /* eslint-enable no-console */ - - /** - * Setup error capture - */ - private setupErrorCapture(): void { - if (typeof window === 'undefined') return; - - const handler = (event: ErrorEvent) => { - this.log('error', event.message, { - module: 'window.onerror', - source: 'captured', - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - error: event.error?.stack, - }); - this.breadcrumbs.add('error', `Uncaught: ${event.message.slice(0, 100)}`); - }; - - window.addEventListener('error', handler); - this.breadcrumbs.add('diagnostics', 'Error capture enabled'); - } - - /** - * Get auth token - */ - private async getAuthToken(): Promise { - if (!this.config.getAuthToken) return null; - try { - const token = await this.config.getAuthToken(); - return token; - } catch { - return null; - } - } - - /** - * Generate unique ID - */ - private generateId(): string { - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/device.ts b/vendor/bytelyst/diagnostics-client/src/device.ts deleted file mode 100644 index efe43b7..0000000 --- a/vendor/bytelyst/diagnostics-client/src/device.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Device state collector — memory, battery, storage, network - * - * @module device - */ - -import type { DeviceState } from './types.js'; - -// DOM type declarations for ESLint -type Navigator = { - onLine: boolean; - connection?: { effectiveType?: string }; - getBattery?: () => Promise<{ charging: boolean; level: number }>; - storage?: { estimate(): Promise<{ usage?: number }> }; -}; -declare const navigator: Navigator; -declare const performance: { memory?: { usedJSHeapSize: number } }; -interface Window { - addEventListener: (type: string, listener: () => void) => void; - removeEventListener: (type: string, listener: () => void) => void; -} -declare const window: Window; - -/** - * Collect current device state - * Best-effort: some APIs may not be available in all environments - */ -export function collectDeviceState(): DeviceState { - const state: DeviceState = { - isOnline: navigator.onLine ?? true, - }; - - // Network type (experimental API) - const connection = (navigator as { connection?: { effectiveType?: string } }).connection; - if (connection) { - state.networkType = connection.effectiveType ?? 'unknown'; - } - - // Battery API (experimental, not widely supported) - // Note: Battery API is deprecated but still useful for diagnostics - const battery = ( - navigator as { getBattery?: () => Promise<{ charging: boolean; level: number }> } - ).getBattery; - if (battery) { - // We'll return a promise, but sync API can't wait - // Store last known value if available - } - - // Memory (Chrome-only experimental) - const memory = (performance as { memory?: { usedJSHeapSize: number } }).memory; - if (memory) { - state.memoryMB = Math.round(memory.usedJSHeapSize / 1024 / 1024); - } - - // Storage (async, but we'll fire-and-forget) - if (navigator.storage && navigator.storage.estimate) { - navigator.storage - .estimate() - .then(estimate => { - if (estimate.usage !== undefined) { - state.storageMB = Math.round(estimate.usage / 1024 / 1024); - } - }) - .catch(() => { - // Ignore errors - }); - } - - return state; -} - -/** - * Subscribe to online/offline events - */ -export function subscribeToConnectivity(callback: (isOnline: boolean) => void): () => void { - const handleOnline = () => callback(true); - const handleOffline = () => callback(false); - - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); - - return () => { - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); - }; -} diff --git a/vendor/bytelyst/diagnostics-client/src/index.ts b/vendor/bytelyst/diagnostics-client/src/index.ts deleted file mode 100644 index 426000b..0000000 --- a/vendor/bytelyst/diagnostics-client/src/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @bytelyst/diagnostics-client - * - * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. - * Provides polling, logging, tracing, network capture, and breadcrumbs. - * - * @example - * ```typescript - * import { DiagnosticsClient } from '@bytelyst/diagnostics-client'; - * - * const client = DiagnosticsClient.getInstance({ - * productId: 'myapp', - * anonymousInstallId: 'install_123', - * platform: 'web', - * channel: 'web_app', - * osFamily: 'macos', - * appVersion: '1.0.0', - * buildNumber: '100', - * releaseChannel: 'stable', - * serverUrl: 'https://api.bytelyst.com', - * }); - * - * await client.start(); - * - * // Auto-instrumented trace - * const result = await client.trace('fetchUser', async () => { - * return await fetch('/api/user').then(r => r.json()); - * }); - * - * // Manual breadcrumb - * client.breadcrumb('user', 'Clicked submit button', { formId: 'signup' }); - * - * // Manual log - * client.log('info', 'User signed up', { userId: '123' }); - * ``` - */ - -export { DiagnosticsClient, type DiagnosticsClientOptions } from './client.js'; - -export { createWebDiagnostics, type WebDiagnosticsConfig } from './web.js'; - -export { BreadcrumbTrail, type BreadcrumbTrailOptions } from './breadcrumbs.js'; - -export { NetworkInterceptor, type NetworkInterceptorOptions } from './network.js'; - -export { collectDeviceState, subscribeToConnectivity } from './device.js'; - -export type { - LogLevel, - SessionStatus, - CollectionLevel, - DiagnosticsSession, - TraceSpan, - LogEntry, - Breadcrumb, - NetworkRequest, - DeviceState, - DiagnosticsConfig, - ClientState, - IngestBatch, -} from './types.js'; diff --git a/vendor/bytelyst/diagnostics-client/src/network.ts b/vendor/bytelyst/diagnostics-client/src/network.ts deleted file mode 100644 index dfc3956..0000000 --- a/vendor/bytelyst/diagnostics-client/src/network.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Network interceptor — capture HTTP requests/responses - * - * @module network - */ - -import type { NetworkRequest } from './types.js'; - -// DOM type declarations for ESLint -type RequestInfo = string | Request | URL; -type HeadersInit = Headers | Record | string[][]; - -export interface NetworkInterceptorOptions { - /** URL patterns to include (default: all) */ - includePatterns?: RegExp[]; - /** URL patterns to exclude */ - excludePatterns?: RegExp[]; - /** Max request body size to capture (default: 100KB) */ - maxBodySize?: number; - /** Whether to capture request headers (default: true) */ - captureRequestHeaders?: boolean; - /** Whether to capture response headers (default: true) */ - captureResponseHeaders?: boolean; - /** Sanitize header values matching these patterns */ - sensitiveHeaderPatterns?: RegExp[]; -} - -/** - * Interceptor for capturing network requests - */ -export class NetworkInterceptor { - private options: Required; - private originalFetch: typeof fetch; - private isActive = false; - private pendingRequests = new Map(); - private onRequest: (request: NetworkRequest) => void; - - constructor( - onRequest: (request: NetworkRequest) => void, - options: NetworkInterceptorOptions = {} - ) { - this.onRequest = onRequest; - this.options = { - includePatterns: options.includePatterns ?? [], - excludePatterns: options.excludePatterns ?? [], - maxBodySize: options.maxBodySize ?? 100 * 1024, - captureRequestHeaders: options.captureRequestHeaders ?? true, - captureResponseHeaders: options.captureResponseHeaders ?? true, - sensitiveHeaderPatterns: options.sensitiveHeaderPatterns ?? [ - /authorization/i, - /cookie/i, - /token/i, - /api-key/i, - ], - }; - this.originalFetch = globalThis.fetch.bind(globalThis); - } - - /** - * Start intercepting fetch calls - */ - start(): void { - if (this.isActive) return; - this.isActive = true; - - globalThis.fetch = this.interceptedFetch.bind(this); - } - - /** - * Stop intercepting fetch calls - */ - stop(): void { - if (!this.isActive) return; - this.isActive = false; - - globalThis.fetch = this.originalFetch; - } - - /** - * Check if URL should be captured - */ - private shouldCapture(url: string): boolean { - // Check excludes first - for (const pattern of this.options.excludePatterns) { - if (pattern.test(url)) return false; - } - - // If includes specified, must match one - if (this.options.includePatterns.length > 0) { - for (const pattern of this.options.includePatterns) { - if (pattern.test(url)) return true; - } - return false; - } - - return true; - } - - /** - * Sanitize headers - */ - private sanitizeHeaders( - headers: HeadersInit | undefined - ): Record { - const sanitized: Record = {}; - const headerEntries = headers instanceof Headers - ? Array.from(headers.entries()) - : typeof headers === 'object' && headers !== null - ? Object.entries(headers) - : []; - - for (const [key, value] of headerEntries) { - const isSensitive = this.options.sensitiveHeaderPatterns.some(p => - p.test(key) - ); - sanitized[key] = isSensitive ? '[REDACTED]' : value; - } - - return sanitized; - } - - /** - * Generate request ID - */ - private generateId(): string { - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`; - } - - /** - * Intercepted fetch implementation - */ - private async interceptedFetch( - input: RequestInfo | URL, - init?: RequestInit - ): Promise { - const url = input.toString(); - const shouldCapture = this.shouldCapture(url); - - const requestId = this.generateId(); - const startTime = new Date().toISOString(); - - // Create request record if capturing - if (shouldCapture) { - const request: NetworkRequest = { - id: requestId, - url: url.slice(0, 2048), // Limit URL length - method: (init?.method ?? 'GET').toUpperCase(), - requestHeaders: this.options.captureRequestHeaders - ? this.sanitizeHeaders(init?.headers) - : {}, - startTime, - }; - - // Capture request body if present and not too large - if (init?.body && typeof init.body === 'string') { - if (init.body.length <= this.options.maxBodySize) { - request.requestBody = init.body.slice(0, this.options.maxBodySize); - } - } - - this.pendingRequests.set(requestId, request); - } - - try { - const response = await this.originalFetch(input, init); - - // Update with response info if capturing - if (shouldCapture) { - const request = this.pendingRequests.get(requestId); - if (request) { - request.status = response.status; - request.endTime = new Date().toISOString(); - request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); - - if (this.options.captureResponseHeaders) { - request.responseHeaders = this.sanitizeHeaders( - Object.fromEntries(response.headers.entries()) - ); - } - - // Don't capture response body (too large/complex) - // Just record that we received a response - - this.pendingRequests.delete(requestId); - this.onRequest(request); - } - } - - return response; - } catch (error) { - // Record error if capturing - if (shouldCapture) { - const request = this.pendingRequests.get(requestId); - if (request) { - request.endTime = new Date().toISOString(); - request.durationMs = new Date(request.endTime).getTime() - new Date(startTime).getTime(); - request.error = error instanceof Error ? error.message : String(error); - - this.pendingRequests.delete(requestId); - this.onRequest(request); - } - } - - throw error; - } - } - - /** - * Check if interceptor is active - */ - isRunning(): boolean { - return this.isActive; - } -} diff --git a/vendor/bytelyst/diagnostics-client/src/types.ts b/vendor/bytelyst/diagnostics-client/src/types.ts deleted file mode 100644 index c7d3c47..0000000 --- a/vendor/bytelyst/diagnostics-client/src/types.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Core types for @bytelyst/diagnostics-client - * - * @module types - */ - -/** - * Log severity levels (matches syslog/OpenTelemetry) - */ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - -/** - * Session status from the server - */ -export type SessionStatus = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled'; - -/** - * Collection level determines verbosity of captured data - */ -export type CollectionLevel = 'standard' | 'debug' | 'trace'; - -/** - * Diagnostic session configuration from server - */ -export interface DiagnosticsSession { - /** Session ID */ - id: string; - /** Product identifier */ - productId: string; - /** Current session status */ - status: SessionStatus; - /** Collection verbosity level */ - collectionLevel: CollectionLevel; - /** Whether to capture logs */ - captureLogs: boolean; - /** Whether to capture network traces */ - captureNetwork: boolean; - /** Whether to capture screenshots */ - captureScreenshots: boolean; - /** Auto-capture screenshot on error */ - screenshotOnError: boolean; - /** Maximum session duration in minutes */ - maxDurationMinutes: number; - /** Session creation time */ - createdAt: string; - /** Session expiry time */ - expiresAt: string; -} - -/** - * OpenTelemetry-compatible trace span - */ -export interface TraceSpan { - /** Unique span ID */ - spanId: string; - /** Parent span ID (null for root) */ - parentId?: string; - /** Operation name */ - name: string; - /** Span kind */ - kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer'; - /** Start time (ISO 8601) */ - startTime: string; - /** End time (ISO 8601) */ - endTime?: string; - /** Duration in milliseconds */ - durationMs?: number; - /** Custom attributes */ - attributes: Record; - /** Status */ - status: 'ok' | 'error' | 'unset'; - /** Error message if status=error */ - statusMessage?: string; - /** Nested events within this span */ - events?: Array<{ - name: string; - timestamp: string; - attributes?: Record; - }>; -} - -/** - * Structured log entry - */ -export interface LogEntry { - /** Log level */ - level: LogLevel; - /** Log message (PII redacted server-side) */ - message: string; - /** Timestamp (ISO 8601) */ - timestamp: string; - /** Module/component name */ - module: string; - /** Source file path */ - file?: string; - /** Line number */ - line?: number; - /** Function name */ - function?: string; - /** Additional context */ - context: Record; - /** Correlation ID for related operations */ - correlationId?: string; -} - -/** - * Breadcrumb for timeline navigation - */ -export interface Breadcrumb { - /** Timestamp */ - timestamp: string; - /** Category (e.g., 'navigation', 'user', 'error') */ - category: string; - /** Message */ - message: string; - /** Associated data */ - data?: Record; -} - -/** - * Network request/response capture - */ -export interface NetworkRequest { - /** Unique request ID */ - id: string; - /** URL */ - url: string; - /** HTTP method */ - method: string; - /** Request headers (sanitized) */ - requestHeaders: Record; - /** Request body (if captured) */ - requestBody?: string; - /** Response status */ - status?: number; - /** Response headers */ - responseHeaders?: Record; - /** Response body (if captured) */ - responseBody?: string; - /** Start timestamp */ - startTime: string; - /** End timestamp */ - endTime?: string; - /** Duration in milliseconds */ - durationMs?: number; - /** Error if request failed */ - error?: string; -} - -/** - * Device state snapshot - */ -export interface DeviceState { - /** Memory usage in MB */ - memoryMB?: number; - /** Battery level (0-1) */ - batteryLevel?: number; - /** Is battery charging */ - isCharging?: boolean; - /** Available storage in MB */ - storageMB?: number; - /** Network type (wifi, cellular, offline) */ - networkType?: string; - /** Is device online */ - isOnline: boolean; - /** Thermal state (nominal, fair, serious, critical) */ - thermalState?: 'nominal' | 'fair' | 'serious' | 'critical'; -} - -/** - * Client configuration options - */ -export interface DiagnosticsConfig { - /** Product ID */ - productId: string; - /** User ID (if authenticated) */ - userId?: string; - /** Anonymous install ID */ - anonymousInstallId: string; - /** Platform name */ - platform: string; - /** Platform channel */ - channel: string; - /** OS family */ - osFamily: string; - /** App version */ - appVersion: string; - /** Build number */ - buildNumber: string; - /** Release channel */ - releaseChannel: string; - /** Server base URL */ - serverUrl: string; - /** Auth token provider */ - getAuthToken?: () => string | Promise; - /** Polling interval in ms (default: 5000) */ - pollIntervalMs?: number; - /** Max breadcrumbs to keep (default: 100) */ - maxBreadcrumbs?: number; - /** Auto-capture console logs */ - captureConsole?: boolean; - /** Auto-capture uncaught errors */ - captureErrors?: boolean; - /** Auto-capture network requests */ - captureNetwork?: boolean; - /** URL patterns to exclude from network capture */ - networkExcludePatterns?: RegExp[]; -} - -/** - * Client state - */ -export type ClientState = - | { type: 'idle' } - | { type: 'polling'; session: DiagnosticsSession | null } - | { type: 'active'; session: DiagnosticsSession } - | { type: 'error'; error: Error }; - -/** - * Ingest batch for sending to server - */ -export interface IngestBatch { - /** Session ID */ - sessionId: string; - /** Traces to ingest */ - traces?: TraceSpan[]; - /** Logs to ingest */ - logs?: LogEntry[]; - /** Breadcrumbs to ingest */ - breadcrumbs?: Breadcrumb[]; - /** Network requests to ingest */ - network?: NetworkRequest[]; -} diff --git a/vendor/bytelyst/diagnostics-client/src/web.ts b/vendor/bytelyst/diagnostics-client/src/web.ts deleted file mode 100644 index 25aac86..0000000 --- a/vendor/bytelyst/diagnostics-client/src/web.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Convenience factory for web dashboard diagnostics. - * - * Eliminates ~40 lines of boilerplate per web app by wrapping - * DiagnosticsClient.getInstance() with sensible web defaults. - * - * @example - * ```ts - * import { createWebDiagnostics } from '@bytelyst/diagnostics-client'; - * - * const { init, stop } = createWebDiagnostics({ - * productId: 'nomgap', - * channel: 'nomgap_web', - * serverUrl: 'http://localhost:4003', - * getAuthToken: () => localStorage.getItem('nomgap_access_token') ?? '', - * }); - * export { init as initDiagnostics, stop as stopDiagnostics }; - * ``` - */ - -import { DiagnosticsClient } from './client.js'; - -export interface WebDiagnosticsConfig { - /** Product identifier (e.g. 'nomgap', 'chronomind'). */ - productId: string; - /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ - channel: string; - /** Platform-service origin URL (no trailing /api). */ - serverUrl: string; - /** Function that returns the current auth token. */ - getAuthToken: () => string; - /** App version string. Default: '0.1.0'. */ - appVersion?: string; - /** Build number. Default: '1'. */ - buildNumber?: string; - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - /** OS family. Default: 'unknown'. */ - osFamily?: string; - /** Poll interval in ms. Default: 30000. */ - pollIntervalMs?: number; - /** Capture console logs. Default: false. */ - captureConsole?: boolean; - /** Capture JS errors. Default: true. */ - captureErrors?: boolean; - /** Capture network requests. Default: false. */ - captureNetwork?: boolean; -} - -export interface WebDiagnostics { - /** Initialize diagnostics. Safe to call on server (no-ops). Idempotent. */ - init(): void; - /** Stop diagnostics polling. */ - stop(): void; -} - -function getOrCreateInstallId(productId: string): string { - const key = `${productId}_diag_install_id`; - let id = localStorage.getItem(key); - if (!id) { - id = - typeof crypto?.randomUUID === 'function' - ? crypto.randomUUID() - : Math.random().toString(36).slice(2) + Date.now().toString(36); - localStorage.setItem(key, id); - } - return id; -} - -export function createWebDiagnostics(config: WebDiagnosticsConfig): WebDiagnostics { - let started = false; - - function init(): void { - if (typeof window === 'undefined') return; - if (started) return; - - DiagnosticsClient.getInstance({ - productId: config.productId, - anonymousInstallId: getOrCreateInstallId(config.productId), - platform: 'web', - channel: config.channel, - osFamily: config.osFamily ?? 'unknown', - appVersion: config.appVersion ?? '0.1.0', - buildNumber: config.buildNumber ?? '1', - releaseChannel: config.releaseChannel ?? 'dev', - serverUrl: config.serverUrl, - getAuthToken: config.getAuthToken, - pollIntervalMs: config.pollIntervalMs ?? 30_000, - captureConsole: config.captureConsole ?? false, - captureErrors: config.captureErrors ?? true, - captureNetwork: config.captureNetwork ?? false, - }); - - DiagnosticsClient.getInstance() - .start() - .catch(() => { - // Diagnostics is best-effort - }); - started = true; - } - - function stop(): void { - try { - DiagnosticsClient.getInstance().stop(); - } catch { - // not initialized - } - } - - return { init, stop }; -} diff --git a/vendor/bytelyst/diagnostics-client/tsconfig.json b/vendor/bytelyst/diagnostics-client/tsconfig.json deleted file mode 100644 index c63c563..0000000 --- a/vendor/bytelyst/diagnostics-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/errors/package.json b/vendor/bytelyst/errors/package.json deleted file mode 100644 index 1782252..0000000 --- a/vendor/bytelyst/errors/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@bytelyst/errors", - "version": "0.1.6", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/errors/src/__tests__/errors.test.ts b/vendor/bytelyst/errors/src/__tests__/errors.test.ts deleted file mode 100644 index b9aa6a0..0000000 --- a/vendor/bytelyst/errors/src/__tests__/errors.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - BadRequestError, - ConflictError, - ForbiddenError, - NotFoundError, - ServiceError, - TooManyRequestsError, - UnauthorizedError, -} from '../index.js'; - -describe('ServiceError', () => { - it('sets statusCode and message', () => { - const err = new ServiceError(500, 'boom'); - expect(err.statusCode).toBe(500); - expect(err.message).toBe('boom'); - expect(err).toBeInstanceOf(Error); - expect(err).toBeInstanceOf(ServiceError); - }); - - it('supports optional details', () => { - const err = new ServiceError(500, 'boom', { field: 'name' }); - expect(err.details).toEqual({ field: 'name' }); - }); -}); - -describe('HTTP error classes', () => { - const cases: [string, new () => ServiceError, number, string][] = [ - ['BadRequestError', BadRequestError, 400, 'Bad request'], - ['UnauthorizedError', UnauthorizedError, 401, 'Unauthorized'], - ['ForbiddenError', ForbiddenError, 403, 'Forbidden'], - ['NotFoundError', NotFoundError, 404, 'Not found'], - ['ConflictError', ConflictError, 409, 'Conflict'], - ['TooManyRequestsError', TooManyRequestsError, 429, 'Too many requests'], - ]; - - for (const [name, Ctor, expectedStatus, expectedMessage] of cases) { - it(`${name} has status ${expectedStatus}`, () => { - const err = new Ctor(); - expect(err.statusCode).toBe(expectedStatus); - expect(err.message).toBe(expectedMessage); - expect(err).toBeInstanceOf(ServiceError); - }); - } - - it('accepts custom message', () => { - const err = new NotFoundError('User not found'); - expect(err.message).toBe('User not found'); - expect(err.statusCode).toBe(404); - }); - - it('accepts details', () => { - const err = new TooManyRequestsError('Rate limited', { retryAfter: 60 }); - expect(err.details).toEqual({ retryAfter: 60 }); - }); -}); diff --git a/vendor/bytelyst/errors/src/http-errors.ts b/vendor/bytelyst/errors/src/http-errors.ts deleted file mode 100644 index 3638046..0000000 --- a/vendor/bytelyst/errors/src/http-errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ServiceError } from './service-error.js'; - -export class BadRequestError extends ServiceError { - constructor(message = 'Bad request', details?: Record) { - super(400, message, details); - } -} - -export class UnauthorizedError extends ServiceError { - constructor(message = 'Unauthorized', details?: Record) { - super(401, message, details); - } -} - -export class ForbiddenError extends ServiceError { - constructor(message = 'Forbidden', details?: Record) { - super(403, message, details); - } -} - -export class NotFoundError extends ServiceError { - constructor(message = 'Not found', details?: Record) { - super(404, message, details); - } -} - -export class ConflictError extends ServiceError { - constructor(message = 'Conflict', details?: Record) { - super(409, message, details); - } -} - -export class TooManyRequestsError extends ServiceError { - constructor(message = 'Too many requests', details?: Record) { - super(429, message, details); - } -} diff --git a/vendor/bytelyst/errors/src/index.ts b/vendor/bytelyst/errors/src/index.ts deleted file mode 100644 index 2464537..0000000 --- a/vendor/bytelyst/errors/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ServiceError } from './service-error.js'; -export { - BadRequestError, - UnauthorizedError, - ForbiddenError, - NotFoundError, - ConflictError, - TooManyRequestsError, -} from './http-errors.js'; diff --git a/vendor/bytelyst/errors/src/service-error.ts b/vendor/bytelyst/errors/src/service-error.ts deleted file mode 100644 index 1bc957f..0000000 --- a/vendor/bytelyst/errors/src/service-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Base error class for typed HTTP service errors. - * All specific error types extend this class. - */ -export class ServiceError extends Error { - constructor( - public statusCode: number, - message: string, - public details?: Record - ) { - super(message); - this.name = 'ServiceError'; - } -} diff --git a/vendor/bytelyst/errors/tsconfig.json b/vendor/bytelyst/errors/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/errors/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/event-store/package.json b/vendor/bytelyst/event-store/package.json deleted file mode 100644 index 5f94b35..0000000 --- a/vendor/bytelyst/event-store/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/event-store", - "version": "0.1.5", - "description": "Persistent event store with pluggable backends (in-memory, file, Cosmos) for ByteLyst product backends", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/event-store/src/file-store.test.ts b/vendor/bytelyst/event-store/src/file-store.test.ts deleted file mode 100644 index 1ee1810..0000000 --- a/vendor/bytelyst/event-store/src/file-store.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import { FileEventStore } from './file-store.js'; -import type { StoredEvent } from './types.js'; - -function makeEvent(overrides?: Partial): StoredEvent { - return { - id: crypto.randomUUID(), - type: 'test.event', - userId: 'u1', - productId: 'testprod', - timestamp: new Date().toISOString(), - payload: {}, - ...overrides, - }; -} - -describe('FileEventStore', () => { - let store: FileEventStore; - let filePath: string; - - beforeEach(() => { - filePath = join( - tmpdir(), - `event-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl` - ); - store = new FileEventStore({ filePath }); - }); - - afterEach(async () => { - try { - await rm(filePath); - } catch { - /* may not exist */ - } - }); - - it('appends and retrieves events', async () => { - await store.append(makeEvent()); - expect(await store.count()).toBe(1); - const recent = await store.recent(); - expect(recent).toHaveLength(1); - }); - - it('persists multiple events across reads', async () => { - await store.append(makeEvent({ id: 'e1' })); - await store.append(makeEvent({ id: 'e2' })); - await store.append(makeEvent({ id: 'e3' })); - expect(await store.count()).toBe(3); - const recent = await store.recent(2); - expect(recent).toHaveLength(2); - expect(recent[0].id).toBe('e2'); - }); - - it('queries by userId', async () => { - await store.append(makeEvent({ userId: 'u1' })); - await store.append(makeEvent({ userId: 'u2' })); - await store.append(makeEvent({ userId: 'u1' })); - const results = await store.query({ userId: 'u1' }); - expect(results).toHaveLength(2); - }); - - it('queries by type', async () => { - await store.append(makeEvent({ type: 'task.created' })); - await store.append(makeEvent({ type: 'schedule.generated' })); - const results = await store.query({ type: 'task.created' }); - expect(results).toHaveLength(1); - }); - - it('queries with time range', async () => { - await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); - const results = await store.query({ - after: '2026-02-01T00:00:00Z', - before: '2026-04-01T00:00:00Z', - }); - expect(results).toHaveLength(1); - }); - - it('queries with limit', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent()); - } - const results = await store.query({ limit: 3 }); - expect(results).toHaveLength(3); - }); - - it('clears all events', async () => { - await store.append(makeEvent()); - await store.append(makeEvent()); - await store.clear(); - expect(await store.count()).toBe(0); - }); - - it('returns empty for non-existent file', async () => { - const fresh = new FileEventStore({ - filePath: join(tmpdir(), 'nonexistent-' + Date.now() + '.jsonl'), - }); - expect(await fresh.count()).toBe(0); - expect(await fresh.recent()).toEqual([]); - }); - - it('creates parent directory if needed', async () => { - const nested = join(tmpdir(), `nested-${Date.now()}`, 'sub', 'events.jsonl'); - const nestedStore = new FileEventStore({ filePath: nested }); - await nestedStore.append(makeEvent()); - expect(await nestedStore.count()).toBe(1); - await rm(join(tmpdir(), `nested-${Date.now()}`), { recursive: true }).catch(() => {}); - }); -}); diff --git a/vendor/bytelyst/event-store/src/file-store.ts b/vendor/bytelyst/event-store/src/file-store.ts deleted file mode 100644 index 88b8df1..0000000 --- a/vendor/bytelyst/event-store/src/file-store.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * File-based event store implementation. - * Appends events as JSON lines to a file on disk. - * Suitable for single-instance dev/staging deployments. - */ - -import { readFile, appendFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; - -export interface FileStoreOptions { - filePath: string; -} - -export class FileEventStore implements EventStore { - private readonly filePath: string; - - constructor(options: FileStoreOptions) { - this.filePath = options.filePath; - } - - async append(event: StoredEvent): Promise { - await mkdir(dirname(this.filePath), { recursive: true }); - await appendFile(this.filePath, JSON.stringify(event) + '\n', 'utf-8'); - } - - async query(q: EventStoreQuery): Promise { - const all = await this.readAll(); - let results = all; - - if (q.userId) results = results.filter(e => e.userId === q.userId); - if (q.type) results = results.filter(e => e.type === q.type); - if (q.after) results = results.filter(e => e.timestamp > q.after!); - if (q.before) results = results.filter(e => e.timestamp < q.before!); - - if (q.limit && q.limit > 0) { - results = results.slice(-q.limit); - } - - return results; - } - - async recent(limit = 50): Promise { - const all = await this.readAll(); - return all.slice(-limit); - } - - async count(): Promise { - const all = await this.readAll(); - return all.length; - } - - async clear(): Promise { - await writeFile(this.filePath, '', 'utf-8'); - } - - private async readAll(): Promise { - try { - const content = await readFile(this.filePath, 'utf-8'); - return content - .split('\n') - .filter(line => line.trim()) - .map(line => JSON.parse(line) as StoredEvent); - } catch { - return []; - } - } -} diff --git a/vendor/bytelyst/event-store/src/index.ts b/vendor/bytelyst/event-store/src/index.ts deleted file mode 100644 index 594f451..0000000 --- a/vendor/bytelyst/event-store/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; -export { MemoryEventStore } from './memory-store.js'; -export type { MemoryStoreOptions } from './memory-store.js'; -export { FileEventStore } from './file-store.js'; -export type { FileStoreOptions } from './file-store.js'; diff --git a/vendor/bytelyst/event-store/src/memory-store.test.ts b/vendor/bytelyst/event-store/src/memory-store.test.ts deleted file mode 100644 index 953abfc..0000000 --- a/vendor/bytelyst/event-store/src/memory-store.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryEventStore } from './memory-store.js'; -import type { StoredEvent } from './types.js'; - -function makeEvent(overrides?: Partial): StoredEvent { - return { - id: crypto.randomUUID(), - type: 'test.event', - userId: 'u1', - productId: 'testprod', - timestamp: new Date().toISOString(), - payload: {}, - ...overrides, - }; -} - -describe('MemoryEventStore', () => { - let store: MemoryEventStore; - - beforeEach(() => { - store = new MemoryEventStore({ maxEvents: 100 }); - }); - - it('appends and retrieves events', async () => { - await store.append(makeEvent()); - expect(await store.count()).toBe(1); - const recent = await store.recent(); - expect(recent).toHaveLength(1); - }); - - it('caps at maxEvents', async () => { - for (let i = 0; i < 120; i++) { - await store.append(makeEvent({ id: `e${i}` })); - } - expect(await store.count()).toBe(100); - }); - - it('queries by userId', async () => { - await store.append(makeEvent({ userId: 'u1' })); - await store.append(makeEvent({ userId: 'u2' })); - await store.append(makeEvent({ userId: 'u1' })); - const results = await store.query({ userId: 'u1' }); - expect(results).toHaveLength(2); - }); - - it('queries by type', async () => { - await store.append(makeEvent({ type: 'task.created' })); - await store.append(makeEvent({ type: 'schedule.generated' })); - await store.append(makeEvent({ type: 'task.created' })); - const results = await store.query({ type: 'task.created' }); - expect(results).toHaveLength(2); - }); - - it('queries with time range', async () => { - await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' })); - await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' })); - const results = await store.query({ - after: '2026-02-01T00:00:00Z', - before: '2026-04-01T00:00:00Z', - }); - expect(results).toHaveLength(1); - }); - - it('queries with limit', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent()); - } - const results = await store.query({ limit: 3 }); - expect(results).toHaveLength(3); - }); - - it('clears all events', async () => { - await store.append(makeEvent()); - await store.append(makeEvent()); - await store.clear(); - expect(await store.count()).toBe(0); - }); - - it('recent returns last N events', async () => { - for (let i = 0; i < 10; i++) { - await store.append(makeEvent({ id: `e${i}` })); - } - const recent = await store.recent(3); - expect(recent).toHaveLength(3); - expect(recent[0].id).toBe('e7'); - }); -}); diff --git a/vendor/bytelyst/event-store/src/memory-store.ts b/vendor/bytelyst/event-store/src/memory-store.ts deleted file mode 100644 index 69ab390..0000000 --- a/vendor/bytelyst/event-store/src/memory-store.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * In-memory event store implementation. - * Useful for development, testing, and as a fallback when no persistent backend is configured. - * Caps at maxEvents to prevent unbounded memory growth. - */ - -import type { EventStore, StoredEvent, EventStoreQuery } from './types.js'; - -export interface MemoryStoreOptions { - maxEvents?: number; -} - -export class MemoryEventStore implements EventStore { - private events: StoredEvent[] = []; - private readonly maxEvents: number; - - constructor(options?: MemoryStoreOptions) { - this.maxEvents = options?.maxEvents ?? 10_000; - } - - async append(event: StoredEvent): Promise { - this.events.push(event); - if (this.events.length > this.maxEvents) { - this.events = this.events.slice(-this.maxEvents); - } - } - - async query(q: EventStoreQuery): Promise { - let results = this.events; - - if (q.userId) results = results.filter(e => e.userId === q.userId); - if (q.type) results = results.filter(e => e.type === q.type); - if (q.after) results = results.filter(e => e.timestamp > q.after!); - if (q.before) results = results.filter(e => e.timestamp < q.before!); - - if (q.limit && q.limit > 0) { - results = results.slice(-q.limit); - } - - return results; - } - - async recent(limit = 50): Promise { - return this.events.slice(-limit); - } - - async count(): Promise { - return this.events.length; - } - - async clear(): Promise { - this.events = []; - } -} diff --git a/vendor/bytelyst/event-store/src/types.ts b/vendor/bytelyst/event-store/src/types.ts deleted file mode 100644 index 5b11418..0000000 --- a/vendor/bytelyst/event-store/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Pluggable event store interface for ByteLyst product backends. - * Products define their own event shapes; the store handles persistence. - */ - -export interface StoredEvent { - id: string; - type: string; - userId: string; - productId: string; - timestamp: string; - payload: Record; -} - -export interface EventStoreQuery { - userId?: string; - type?: string; - after?: string; - before?: string; - limit?: number; -} - -export interface EventStore { - append(event: StoredEvent): Promise; - query(q: EventStoreQuery): Promise; - recent(limit?: number): Promise; - count(): Promise; - clear(): Promise; -} diff --git a/vendor/bytelyst/event-store/tsconfig.json b/vendor/bytelyst/event-store/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/event-store/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json deleted file mode 100644 index 97a233a..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-created.event.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "eventId": "evt_phase1_002", - "eventName": "artifact.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:17:01.000Z", - "productId": "notelett", - "sourceSurface": "web", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "artifactId": "art_note_001", - "actor": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_001", - "parentEventId": "evt_phase1_001" - }, - "payload": { - "artifactType": "note", - "title": "Standup follow-up", - "status": "draft" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json deleted file mode 100644 index 14afb36..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/artifact-linked.event.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "eventId": "evt_phase1_003", - "eventName": "artifact.linked", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:17:02.000Z", - "productId": "notelett", - "sourceSurface": "web", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "artifactId": "art_note_001", - "actor": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_002", - "parentEventId": "evt_phase1_002" - }, - "payload": { - "sourceArtifactId": "art_note_001", - "targetArtifactId": "art_transcript_001", - "relation": "summarizes" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json deleted file mode 100644 index a1bd3f2..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/capture-transcript-created.event.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "eventId": "evt_phase1_001", - "eventName": "capture.transcript.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:15:01.000Z", - "productId": "lysnrai", - "sourceSurface": "mobile", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": null, - "artifactId": "art_transcript_001", - "actor": { - "actorType": "user", - "actorId": "user_saravana" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": null, - "parentEventId": null - }, - "payload": { - "artifactId": "art_transcript_001", - "durationMs": 42150, - "language": "en", - "transcriptSource": "microphone" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json deleted file mode 100644 index 44d219d..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-artifact.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "id": "art_memory_001", - "artifactType": "memory", - "schemaVersion": 1, - "productId": "mindlyst", - "sourceSurface": "service", - "title": "Saravana prefers deployment checklist reminders after standup", - "summary": "Memory candidate inferred from the standup transcript and note.", - "createdAt": "2026-04-03T18:20:00.000Z", - "updatedAt": "2026-04-03T18:20:00.000Z", - "createdBy": { - "actorType": "agent", - "actorId": "memory_ingest_agent" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private" - }, - "status": "proposed", - "tags": ["memory", "workflow"], - "links": [ - { - "relation": "derived-from", - "targetArtifactId": "art_transcript_001" - }, - { - "relation": "generated-memory", - "targetArtifactId": "art_note_001" - } - ], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": "run_memory_001", - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - }, - { - "stepType": "note-created", - "productId": "notelett", - "actorType": "agent", - "timestamp": "2026-04-03T18:17:00.000Z" - }, - { - "stepType": "memory-proposed", - "productId": "mindlyst", - "actorType": "agent", - "timestamp": "2026-04-03T18:20:00.000Z" - } - ] - }, - "payload": { - "memoryKind": "preference", - "text": "Saravana benefits from deployment checklist reminders immediately after standup capture.", - "confidence": 0.82, - "sourceArtifactIds": ["art_transcript_001", "art_note_001"], - "reviewState": "proposed" - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json deleted file mode 100644 index 81bfd92..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/memory-entry-created.event.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "eventId": "evt_phase1_004", - "eventName": "memory.entry.created", - "eventVersion": 1, - "occurredAt": "2026-04-03T18:20:01.000Z", - "productId": "mindlyst", - "sourceSurface": "service", - "userId": "user_saravana", - "orgId": null, - "sessionId": "sess_lysnr_001", - "runId": "run_memory_001", - "artifactId": "art_memory_001", - "actor": { - "actorType": "agent", - "actorId": "memory_ingest_agent" - }, - "trace": { - "correlationId": "corr_phase1_001", - "causationId": "evt_phase1_003", - "parentEventId": "evt_phase1_003" - }, - "payload": { - "artifactId": "art_memory_001", - "memoryKind": "preference", - "reviewState": "proposed", - "confidence": 0.82, - "sourceArtifactIds": ["art_transcript_001", "art_note_001"] - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json deleted file mode 100644 index c785876..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/note-artifact.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "id": "art_note_001", - "artifactType": "note", - "schemaVersion": 1, - "productId": "notelett", - "sourceSurface": "web", - "title": "Standup follow-up", - "summary": "Structured note created from a LysnrAI transcript.", - "createdAt": "2026-04-03T18:17:00.000Z", - "updatedAt": "2026-04-03T18:17:00.000Z", - "createdBy": { - "actorType": "agent", - "actorId": "notes_ingest_agent" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private" - }, - "status": "draft", - "tags": ["derived", "standup"], - "links": [ - { - "relation": "summarizes", - "targetArtifactId": "art_transcript_001" - } - ], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": "run_notes_001", - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - }, - { - "stepType": "note-created", - "productId": "notelett", - "actorType": "agent", - "timestamp": "2026-04-03T18:17:00.000Z" - } - ] - }, - "payload": { - "noteFormat": "markdown", - "body": "# Standup follow-up\n\n- Billing sync finished\n- Clean up follow-up notes\n- Review deployment checklist", - "excerpt": "Billing sync finished; follow-up notes and deployment checklist remain." - } -} diff --git a/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json b/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json deleted file mode 100644 index f696a05..0000000 --- a/vendor/bytelyst/events/fixtures/ecosystem/phase1/transcript-artifact.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "id": "art_transcript_001", - "artifactType": "transcript", - "schemaVersion": 1, - "productId": "lysnrai", - "sourceSurface": "mobile", - "title": "Daily standup voice capture", - "summary": "Transcript captured from Saravana's daily standup reflection.", - "createdAt": "2026-04-03T18:15:00.000Z", - "updatedAt": "2026-04-03T18:15:00.000Z", - "createdBy": { - "actorType": "user", - "actorId": "user_saravana" - }, - "ownership": { - "userId": "user_saravana", - "orgId": null - }, - "visibility": { - "scope": "private", - "allowedProducts": ["learning_ai_notes", "learning_multimodal_memory_agents"] - }, - "status": "completed", - "tags": ["voice", "standup"], - "links": [], - "provenance": { - "originProductId": "lysnrai", - "originActionId": "capture_001", - "sessionId": "sess_lysnr_001", - "runId": null, - "approvalId": null, - "correlationId": "corr_phase1_001", - "lineage": [ - { - "stepType": "captured", - "productId": "lysnrai", - "actorType": "user", - "timestamp": "2026-04-03T18:15:00.000Z" - } - ] - }, - "payload": { - "transcriptText": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist.", - "transcriptSource": "microphone", - "language": "en", - "durationMs": 42150, - "segments": [ - { - "speaker": null, - "startedAtMs": 0, - "endedAtMs": 42150, - "text": "Today I finished the billing sync, I need to clean up follow-up notes, and I should remember to review the deployment checklist." - } - ] - } -} diff --git a/vendor/bytelyst/events/package.json b/vendor/bytelyst/events/package.json deleted file mode 100644 index 444153f..0000000 --- a/vendor/bytelyst/events/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/events", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/queue": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "zod": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/events/src/agent-runtime.test.ts b/vendor/bytelyst/events/src/agent-runtime.test.ts deleted file mode 100644 index 8ae570d..0000000 --- a/vendor/bytelyst/events/src/agent-runtime.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - AgentActionLogSchema, - AgentCheckpointSchema, - AgentApprovalCheckpointSchema, - AgentDispatchRequestSchema, - AgentRunSchema, - AgentSessionSchema, - AgentTaskSchema, - AgentTodoSchema, -} from './agent-runtime.js'; - -describe('agent runtime contract baseline', () => { - it('validates a dispatched Cowork session with task and approval checkpoint', () => { - const session = AgentSessionSchema.parse({ - sessionId: 'sess_cowork_1', - productId: 'cowork', - userId: 'saravana', - status: 'waiting-approval', - startedAt: '2026-04-03T16:00:00.000Z', - updatedAt: '2026-04-03T16:05:00.000Z', - resumable: true, - currentTaskId: 'task_cowork_1', - memoryRefs: ['mem_proj_1'], - artifactRefs: ['art_note_1'], - approvalRefs: ['approval_1'], - dispatchContext: { - originSurface: 'browser', - originProductId: 'notelett', - dispatchMode: 'remote', - initiatedAt: '2026-04-03T15:59:00.000Z', - }, - }); - - const task = AgentTaskSchema.parse({ - taskId: 'task_cowork_1', - sessionId: session.sessionId, - title: 'Investigate imported roadmap risks', - intent: 'Audit the imported note and produce findings', - status: 'blocked', - priority: 'high', - createdAt: '2026-04-03T16:00:00.000Z', - updatedAt: '2026-04-03T16:05:00.000Z', - }); - - const approval = AgentApprovalCheckpointSchema.parse({ - approvalId: 'approval_1', - sessionId: session.sessionId, - runId: 'run_cowork_1', - actionLabel: 'Delete temporary export', - riskLevel: 'high', - status: 'requested', - requestedAt: '2026-04-03T16:05:00.000Z', - resolvedAt: null, - resolverSurface: null, - }); - - expect(session.dispatchContext?.originSurface).toBe('browser'); - expect(task.status).toBe('blocked'); - expect(approval.status).toBe('requested'); - }); - - it('validates a scheduled FlowMonk run with todos and action logs', () => { - const dispatch = AgentDispatchRequestSchema.parse({ - dispatchId: 'dispatch_flow_1', - targetProductId: 'flowmonk', - targetExecutor: 'flowmonk', - userId: 'saravana', - title: 'Execute weekly planning refresh', - intent: 'Rebuild plan, routines, and habits for next week', - artifactRefs: ['art_plan_template_1'], - memoryRefs: ['mem_goal_1'], - dispatchContext: { - originSurface: 'web', - originProductId: 'flowmonk', - dispatchMode: 'scheduled', - initiatedAt: '2026-04-03T17:00:00.000Z', - }, - }); - - const run = AgentRunSchema.parse({ - runId: 'run_flow_1', - sessionId: 'sess_flow_1', - productId: 'flowmonk', - status: 'waiting-approval', - startedAt: '2026-04-03T17:01:00.000Z', - completedAt: null, - checkpointArtifactId: 'art_plan_1', - correlationId: 'corr_phase2', - }); - - const todo = AgentTodoSchema.parse({ - todoId: 'todo_flow_1', - sessionId: 'sess_flow_1', - text: 'Generate routines from approved plan', - status: 'in-progress', - createdAt: '2026-04-03T17:01:30.000Z', - updatedAt: '2026-04-03T17:02:00.000Z', - }); - - const actionLog = AgentActionLogSchema.parse({ - actionLogId: 'alog_flow_1', - sessionId: 'sess_flow_1', - runId: 'run_flow_1', - eventName: 'agent.run.started', - occurredAt: '2026-04-03T17:01:00.000Z', - actorType: 'agent', - correlationId: 'corr_phase2', - payload: { - dispatchId: dispatch.dispatchId, - title: dispatch.title, - }, - }); - - expect(dispatch.dispatchContext.dispatchMode).toBe('scheduled'); - expect(run.status).toBe('waiting-approval'); - expect(run.checkpointArtifactId).toBe('art_plan_1'); - expect(todo.status).toBe('in-progress'); - expect(actionLog.eventName).toBe('agent.run.started'); - }); - - it('accepts queued runs as a first-class runtime state', () => { - const run = AgentRunSchema.parse({ - runId: 'run_queued_1', - sessionId: 'sess_queued_1', - productId: 'clawcowork', - status: 'queued', - startedAt: '2026-04-04T10:00:00.000Z', - completedAt: null, - checkpointArtifactId: null, - correlationId: 'corr_queued_1', - }); - - expect(run.status).toBe('queued'); - }); - - it('validates checkpoint summaries used for resume review', () => { - const checkpoint = AgentCheckpointSchema.parse({ - checkpointId: 'ckpt_cowork_1', - sessionId: 'sess_cowork_1', - runId: 'run_cowork_1', - productId: 'clawcowork', - userId: 'saravana', - createdAt: '2026-04-04T12:00:00.000Z', - statusAtCapture: 'waiting-approval', - currentTaskId: 'task_cowork_1', - checkpointArtifactId: 'artifact://notelett/note-1', - todoIds: ['todo_cowork_1'], - artifactRefs: ['artifact://notelett/note-1'], - memoryRefs: [], - approvalRefs: ['approval_1'], - dispatchContext: { - originSurface: 'desktop', - originProductId: 'clawcowork', - dispatchMode: 'interactive', - initiatedAt: '2026-04-04T11:58:00.000Z', - }, - resumeToken: 'task_cowork_1', - stateSummary: { - title: 'Investigate imported roadmap risks', - summary: 'Paused for approval after scanning the repository and proposing changes.', - lastActionAt: '2026-04-04T12:00:00.000Z', - }, - }); - - expect(checkpoint.statusAtCapture).toBe('waiting-approval'); - expect(checkpoint.resumeToken).toBe('task_cowork_1'); - expect(checkpoint.checkpointArtifactId).toBe('artifact://notelett/note-1'); - }); -}); diff --git a/vendor/bytelyst/events/src/agent-runtime.ts b/vendor/bytelyst/events/src/agent-runtime.ts deleted file mode 100644 index 5ba0ec9..0000000 --- a/vendor/bytelyst/events/src/agent-runtime.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { z } from 'zod'; - -export const AgentDispatchContextSchema = z.object({ - originSurface: z.enum(['browser', 'mobile', 'desktop', 'web', 'product-api']), - originProductId: z.string().min(1), - dispatchMode: z.enum(['interactive', 'queued', 'scheduled', 'remote']), - initiatedAt: z.string().datetime(), -}); - -export const AgentSessionStatusSchema = z.enum([ - 'active', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentSessionSchema = z.object({ - sessionId: z.string().min(1), - productId: z.string().min(1), - userId: z.string().min(1), - status: AgentSessionStatusSchema, - startedAt: z.string().datetime(), - updatedAt: z.string().datetime(), - resumable: z.boolean(), - currentTaskId: z.string().min(1).nullable().optional(), - memoryRefs: z.array(z.string().min(1)), - artifactRefs: z.array(z.string().min(1)), - approvalRefs: z.array(z.string().min(1)), - dispatchContext: AgentDispatchContextSchema.nullable().optional(), -}); - -export const AgentTaskStatusSchema = z.enum([ - 'queued', - 'running', - 'blocked', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentTaskSchema = z.object({ - taskId: z.string().min(1), - sessionId: z.string().min(1), - title: z.string().min(1), - intent: z.string().min(1), - status: AgentTaskStatusSchema, - priority: z.string().min(1).nullable().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -export const AgentTodoStatusSchema = z.enum(['open', 'in-progress', 'done', 'dropped']); - -export const AgentTodoSchema = z.object({ - todoId: z.string().min(1), - sessionId: z.string().min(1), - text: z.string().min(1), - status: AgentTodoStatusSchema, - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), -}); - -export const AgentCheckpointStatusSchema = z.enum([ - 'queued', - 'running', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentCheckpointSchema = z.object({ - checkpointId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1).nullable().optional(), - productId: z.string().min(1), - userId: z.string().min(1), - createdAt: z.string().datetime(), - statusAtCapture: AgentCheckpointStatusSchema, - currentTaskId: z.string().min(1).nullable().optional(), - checkpointArtifactId: z.string().min(1).nullable().optional(), - todoIds: z.array(z.string().min(1)), - artifactRefs: z.array(z.string().min(1)), - memoryRefs: z.array(z.string().min(1)), - approvalRefs: z.array(z.string().min(1)), - dispatchContext: AgentDispatchContextSchema.nullable().optional(), - resumeToken: z.string().min(1).nullable().optional(), - stateSummary: z.object({ - title: z.string().min(1), - summary: z.string().min(1), - lastActionAt: z.string().datetime().nullable().optional(), - }), -}); - -export const AgentRunStatusSchema = z.enum([ - 'queued', - 'running', - 'paused', - 'waiting-approval', - 'completed', - 'failed', - 'cancelled', -]); - -export const AgentRunSchema = z.object({ - runId: z.string().min(1), - sessionId: z.string().min(1), - productId: z.string().min(1), - status: AgentRunStatusSchema, - startedAt: z.string().datetime(), - completedAt: z.string().datetime().nullable().optional(), - checkpointArtifactId: z.string().min(1).nullable().optional(), - correlationId: z.string().min(1).nullable().optional(), -}); - -export const AgentApprovalCheckpointSchema = z.object({ - approvalId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1), - actionLabel: z.string().min(1), - riskLevel: z.enum(['low', 'medium', 'high', 'critical']), - status: z.enum(['requested', 'approved', 'denied', 'expired']), - requestedAt: z.string().datetime(), - resolvedAt: z.string().datetime().nullable().optional(), - resolverSurface: z.enum(['mobile', 'web', 'desktop']).nullable().optional(), -}); - -export const AgentDispatchRequestSchema = z.object({ - dispatchId: z.string().min(1), - targetProductId: z.string().min(1), - targetExecutor: z.enum(['cowork', 'jarvisjr', 'flowmonk', 'generic-agent']), - userId: z.string().min(1), - title: z.string().min(1), - intent: z.string().min(1), - artifactRefs: z.array(z.string().min(1)).default([]), - memoryRefs: z.array(z.string().min(1)).default([]), - dispatchContext: AgentDispatchContextSchema, -}); - -export const AgentActionLogSchema = z.object({ - actionLogId: z.string().min(1), - sessionId: z.string().min(1), - runId: z.string().min(1), - eventName: z.string().min(1), - occurredAt: z.string().datetime(), - actorType: z.enum(['user', 'agent', 'system', 'device']), - correlationId: z.string().min(1).nullable().optional(), - payload: z.record(z.unknown()), -}); - -export type AgentDispatchContext = z.infer; -export type AgentSession = z.infer; -export type AgentTask = z.infer; -export type AgentTodo = z.infer; -export type AgentCheckpoint = z.infer; -export type AgentRun = z.infer; -export type AgentApprovalCheckpoint = z.infer; -export type AgentDispatchRequest = z.infer; -export type AgentActionLog = z.infer; diff --git a/vendor/bytelyst/events/src/durable.test.ts b/vendor/bytelyst/events/src/durable.test.ts deleted file mode 100644 index b2733a7..0000000 --- a/vendor/bytelyst/events/src/durable.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { describe, it, expect, vi } from 'vitest'; -import { FileQueueStore } from '@bytelyst/queue'; -import { MemoryQueueStore } from '@bytelyst/queue'; -import { DurableEventBus } from './durable.js'; - -describe('DurableEventBus', () => { - it('delivers queued events through the worker', async () => { - const store = new MemoryQueueStore(); - const bus = new DurableEventBus({ - store, - autoStart: false, - pollIntervalMs: 10, - }); - - const handler = vi.fn(); - bus.on('user.created', handler); - bus.start(); - - const result = await bus.emit('user.created', { - userId: 'u1', - email: 'test@example.com', - plan: 'free', - productId: 'lysnrai', - }); - - expect(result.handlerCount).toBe(1); - - await waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); - - await bus.stop(); - }); - - it('persists emitted events across bus instances before the worker starts', async () => { - const dir = await mkdtemp(join(tmpdir(), 'events-store-')); - const filePath = join(dir, 'events.json'); - - try { - const first = new DurableEventBus({ - store: new FileQueueStore({ filePath }), - autoStart: false, - pollIntervalMs: 10, - }); - - await first.emit('payment.failed', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 499, - retryCount: 1, - productId: 'lysnrai', - }); - await first.stop(); - - const second = new DurableEventBus({ - store: new FileQueueStore({ filePath }), - autoStart: false, - pollIntervalMs: 10, - }); - - const handler = vi.fn(); - second.on('payment.failed', handler); - second.start(); - - await waitFor(() => { - expect(handler).toHaveBeenCalledOnce(); - }); - - await second.stop(); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - assertion(); - return; - } catch { - await new Promise(resolve => setTimeout(resolve, 20)); - } - } - - assertion(); -} diff --git a/vendor/bytelyst/events/src/durable.ts b/vendor/bytelyst/events/src/durable.ts deleted file mode 100644 index abb57e9..0000000 --- a/vendor/bytelyst/events/src/durable.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { QueueWorker, type QueueJob, type QueueStore } from '@bytelyst/queue'; -import type { EmitResult } from './memory.js'; -import type { - EventHandler, - EventSubscription, - PlatformEvent, - PlatformEventName, - PlatformEventPayload, -} from './types.js'; - -interface EventEnvelope { - event: PlatformEvent; -} - -export interface DurableEventBusOptions { - store: QueueStore; - queueName?: string; - workerId?: string; - pollIntervalMs?: number; - leaseMs?: number; - backoffMs?: number; - autoStart?: boolean; -} - -export class DurableEventBus { - private readonly handlers = new Map< - string, - Set<{ id: string; fn: EventHandler }> - >(); - private subscriptionCounter = 0; - private readonly queueName: string; - private readonly store: QueueStore; - private readonly worker: QueueWorker; - private running = false; - - constructor(options: DurableEventBusOptions) { - this.queueName = options.queueName ?? 'platform-events'; - this.store = options.store; - this.worker = new QueueWorker({ - queueName: this.queueName, - store: this.store, - workerId: options.workerId, - pollIntervalMs: options.pollIntervalMs, - leaseMs: options.leaseMs, - backoffMs: options.backoffMs, - handler: async job => { - await this.dispatch(job); - }, - }); - - if (options.autoStart !== false) { - this.start(); - } - } - - on(eventType: T, handler: EventHandler): EventSubscription { - const id = `sub_${++this.subscriptionCounter}`; - - if (!this.handlers.has(eventType)) { - this.handlers.set(eventType, new Set()); - } - - const entry = { id, fn: handler as EventHandler }; - this.handlers.get(eventType)!.add(entry); - - return { - id, - eventType, - unsubscribe: () => { - this.handlers.get(eventType)?.delete(entry); - }, - }; - } - - async emit( - eventType: T, - payload: PlatformEventPayload, - options?: { source?: string } - ): Promise { - const event: PlatformEvent = { - id: crypto.randomUUID(), - type: eventType, - payload, - timestamp: new Date().toISOString(), - source: options?.source, - }; - - await this.store.enqueue(this.queueName, { - idempotencyKey: event.id, - type: eventType, - payload: { event }, - productId: extractProductId(payload), - metadata: { - source: options?.source, - }, - }); - - return { - eventId: event.id, - handlerCount: this.listenerCount(eventType), - errors: [], - }; - } - - start(): void { - if (this.running) return; - this.running = true; - this.worker.start(); - } - - async stop(): Promise { - if (!this.running) return; - this.running = false; - await this.worker.stop(); - } - - clear(eventType?: PlatformEventName): void { - if (eventType) { - this.handlers.delete(eventType); - } else { - this.handlers.clear(); - } - } - - listenerCount(eventType: PlatformEventName): number { - return this.handlers.get(eventType)?.size ?? 0; - } - - eventTypes(): PlatformEventName[] { - return Array.from(this.handlers.entries()) - .filter(([, set]) => set.size > 0) - .map(([type]) => type as PlatformEventName); - } - - private async dispatch(job: QueueJob): Promise { - const event = job.payload.event; - const handlers = this.handlers.get(event.type); - if (!handlers || handlers.size === 0) { - return; - } - - await Promise.allSettled( - Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) - ); - } -} - -function extractProductId(payload: unknown): string | undefined { - if (!payload || typeof payload !== 'object') return undefined; - const productId = (payload as { productId?: unknown }).productId; - return typeof productId === 'string' ? productId : undefined; -} diff --git a/vendor/bytelyst/events/src/ecosystem.test.ts b/vendor/bytelyst/events/src/ecosystem.test.ts deleted file mode 100644 index 6f83cc0..0000000 --- a/vendor/bytelyst/events/src/ecosystem.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import transcriptArtifact from '../fixtures/ecosystem/phase1/transcript-artifact.json' with { type: 'json' }; -import noteArtifact from '../fixtures/ecosystem/phase1/note-artifact.json' with { type: 'json' }; -import memoryArtifact from '../fixtures/ecosystem/phase1/memory-artifact.json' with { type: 'json' }; -import captureTranscriptCreatedEvent from '../fixtures/ecosystem/phase1/capture-transcript-created.event.json' with { type: 'json' }; -import artifactCreatedEvent from '../fixtures/ecosystem/phase1/artifact-created.event.json' with { type: 'json' }; -import artifactLinkedEvent from '../fixtures/ecosystem/phase1/artifact-linked.event.json' with { type: 'json' }; -import memoryEntryCreatedEvent from '../fixtures/ecosystem/phase1/memory-entry-created.event.json' with { type: 'json' }; -import { - ArtifactCreatedEventSchema, - ArtifactLinkedEventSchema, - HabitArtifactEnvelopeSchema, - PlanArtifactEnvelopeSchema, - Phase1ArtifactEnvelopeSchema, - Phase1EcosystemEventSchema, - Phase1EcosystemEventSchemas, - RoutineArtifactEnvelopeSchema, - TrailReportArtifactEnvelopeSchema, -} from './ecosystem.js'; - -describe('phase1 ecosystem contracts', () => { - it('validates canonical transcript, note, and memory artifacts', () => { - const transcript = Phase1ArtifactEnvelopeSchema.parse(transcriptArtifact); - const note = Phase1ArtifactEnvelopeSchema.parse(noteArtifact); - const memory = Phase1ArtifactEnvelopeSchema.parse(memoryArtifact); - - expect(transcript.artifactType).toBe('transcript'); - expect(note.links).toContainEqual({ - relation: 'summarizes', - targetArtifactId: transcript.id, - }); - expect(memory.links).toEqual( - expect.arrayContaining([ - { - relation: 'generated-memory', - targetArtifactId: note.id, - }, - ]) - ); - }); - - it('validates canonical phase1 events', () => { - const events = [ - captureTranscriptCreatedEvent, - artifactCreatedEvent, - artifactLinkedEvent, - memoryEntryCreatedEvent, - ].map(event => Phase1EcosystemEventSchema.parse(event)); - - expect(events.map(event => event.eventName)).toEqual([ - 'capture.transcript.created', - 'artifact.created', - 'artifact.linked', - 'memory.entry.created', - ]); - }); - - it('exposes event-specific schemas keyed by canonical event name', () => { - const created = Phase1EcosystemEventSchemas['artifact.created'].parse(artifactCreatedEvent); - const linked = Phase1EcosystemEventSchemas['artifact.linked'].parse(artifactLinkedEvent); - - expect(created.payload.artifactType).toBe('note'); - expect(linked.payload.relation).toBe('summarizes'); - }); -}); - -describe('phase2 ecosystem contract extensions', () => { - it('validates canonical plan, routine, and habit artifacts', () => { - const plan = PlanArtifactEnvelopeSchema.parse({ - id: 'art_plan_demo', - artifactType: 'plan', - schemaVersion: 1, - productId: 'flowmonk', - sourceSurface: 'backend', - title: 'FlowMonk weekly plan', - summary: 'Three focused execution blocks', - createdAt: '2026-04-03T12:00:00.000Z', - updatedAt: '2026-04-03T12:00:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { - scope: 'private', - allowedProducts: ['learning_ai_clock', 'learning_ai_efforise'], - }, - status: 'draft', - tags: ['ecosystem', 'phase2', 'plan'], - links: [], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_plan', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - ], - }, - payload: { - weekOf: '2026-04-07', - taskCount: 3, - scheduledEntryCount: 3, - totalScheduledMinutes: 165, - entries: [ - { - taskTitle: 'Architecture review', - scheduledDate: '2026-04-07', - startTime: '08:00', - endTime: '09:00', - durationMinutes: 60, - flowName: 'Deep Work', - zoneName: 'Studio', - priority: 'high', - }, - ], - }, - }); - - const routine = RoutineArtifactEnvelopeSchema.parse({ - id: 'art_routine_demo', - artifactType: 'routine', - schemaVersion: 1, - productId: 'chronomind', - sourceSurface: 'backend', - title: 'Routine from FlowMonk weekly plan', - summary: 'Three-step execution routine', - createdAt: '2026-04-03T12:05:00.000Z', - updatedAt: '2026-04-03T12:05:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-routine-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { scope: 'private' }, - status: 'ready', - tags: ['ecosystem', 'phase2', 'routine'], - links: [{ relation: 'generated-routine', targetArtifactId: plan.id }], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_routine', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - { - stepType: 'routine-created', - productId: 'chronomind', - actorType: 'agent', - timestamp: '2026-04-03T12:05:00.000Z', - }, - ], - }, - payload: { - routineId: 'routine_demo', - stepCount: 3, - totalDurationMinutes: 165, - status: 'ready', - isTemplate: true, - category: 'phase2-import', - steps: [ - { - label: 'Architecture review', - durationMinutes: 60, - transition: '5m_break', - status: 'pending', - }, - ], - }, - }); - - const habit = HabitArtifactEnvelopeSchema.parse({ - id: 'art_habit_demo', - artifactType: 'habit', - schemaVersion: 1, - productId: 'efforise', - sourceSurface: 'backend', - title: 'Habit from FlowMonk weekly plan', - summary: 'Practice the imported routine daily', - createdAt: '2026-04-03T12:10:00.000Z', - updatedAt: '2026-04-03T12:10:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase2-habit-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { scope: 'private' }, - status: 'active', - tags: ['ecosystem', 'phase2', 'habit'], - links: [{ relation: 'generated-habit', targetArtifactId: routine.id }], - provenance: { - originProductId: 'flowmonk', - originActionId: 'plan_export_1', - sessionId: 'sess_phase2', - runId: 'run_phase2_habit', - approvalId: null, - correlationId: 'corr_phase2', - lineage: [ - { - stepType: 'plan-exported', - productId: 'flowmonk', - actorType: 'agent', - timestamp: '2026-04-03T12:00:00.000Z', - }, - { - stepType: 'routine-created', - productId: 'chronomind', - actorType: 'agent', - timestamp: '2026-04-03T12:05:00.000Z', - }, - { - stepType: 'habit-created', - productId: 'efforise', - actorType: 'agent', - timestamp: '2026-04-03T12:10:00.000Z', - }, - ], - }, - payload: { - habitId: 'habit_demo', - identityId: 'identity_phase2', - frequency: 'daily', - targetCount: 1, - reminderTime: '08:00', - isActive: true, - sourceRoutineId: 'routine_demo', - }, - }); - - expect(routine.links[0]?.targetArtifactId).toBe(plan.id); - expect(habit.links[0]?.targetArtifactId).toBe(routine.id); - }); - - it('accepts generic artifact.created and artifact.linked events for phase2 artifact types', () => { - const created = ArtifactCreatedEventSchema.parse({ - eventId: 'evt_phase2_created', - eventName: 'artifact.created', - eventVersion: 1, - occurredAt: '2026-04-03T12:05:00.000Z', - productId: 'chronomind', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase2', - runId: 'run_phase2_routine', - artifactId: 'art_routine_demo', - actor: { actorType: 'agent', actorId: 'phase2-routine-importer' }, - trace: { - correlationId: 'corr_phase2', - causationId: 'evt_phase2_plan', - parentEventId: 'evt_phase2_plan', - }, - payload: { - artifactType: 'routine', - title: 'Routine from FlowMonk weekly plan', - status: 'ready', - }, - }); - - const linked = ArtifactLinkedEventSchema.parse({ - eventId: 'evt_phase2_linked', - eventName: 'artifact.linked', - eventVersion: 1, - occurredAt: '2026-04-03T12:10:00.000Z', - productId: 'efforise', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase2', - runId: 'run_phase2_habit', - artifactId: 'art_habit_demo', - actor: { actorType: 'agent', actorId: 'phase2-habit-importer' }, - trace: { - correlationId: 'corr_phase2', - causationId: 'evt_phase2_created', - parentEventId: 'evt_phase2_created', - }, - payload: { - sourceArtifactId: 'art_habit_demo', - targetArtifactId: 'art_routine_demo', - relation: 'generated-habit', - }, - }); - - expect(created.payload.artifactType).toBe('routine'); - expect(linked.payload.relation).toBe('generated-habit'); - }); -}); - -describe('phase3 ecosystem contract extensions', () => { - it('validates canonical trail-report artifacts', () => { - const trailReport = TrailReportArtifactEnvelopeSchema.parse({ - id: 'art_trail_demo', - artifactType: 'trail-report', - schemaVersion: 1, - productId: 'actiontrail', - sourceSurface: 'backend', - title: 'Cowork audit report for task task-123', - summary: '4 audited actions with 1 safety signal', - createdAt: '2026-04-03T14:00:00.000Z', - updatedAt: '2026-04-03T14:00:00.000Z', - createdBy: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - ownership: { userId: 'saravana', orgId: null }, - visibility: { - scope: 'private', - allowedProducts: ['learning_ai_notes', 'learning_multimodal_memory_agents'], - }, - status: 'recorded', - tags: ['ecosystem', 'phase3', 'audit'], - links: [], - provenance: { - originProductId: 'claw-cowork', - originActionId: 'task-123', - sessionId: 'sess_phase3', - runId: 'run_phase3_trail', - approvalId: null, - correlationId: 'corr_phase3', - lineage: [ - { - stepType: 'audit-exported', - productId: 'claw-cowork', - actorType: 'system', - timestamp: '2026-04-03T13:55:00.000Z', - }, - { - stepType: 'trail-report-created', - productId: 'actiontrail', - actorType: 'agent', - timestamp: '2026-04-03T14:00:00.000Z', - }, - ], - }, - payload: { - sourceProduct: 'claw-cowork', - sourceTaskId: 'task-123', - generatedFrom: 'audit-export-json', - reportGeneratedAt: '2026-04-03T14:00:00.000Z', - actionCount: 4, - toolCallCount: 2, - approvalCount: 1, - failureCount: 0, - safetySignalCount: 1, - tasks: ['task-123'], - actionBreakdown: [ - { action: 'TaskStarted', count: 1 }, - { action: 'ToolCall', count: 2 }, - { action: 'InjectionDetected', count: 1 }, - ], - entries: [ - { - timestamp: '2026-04-03T13:55:00.000Z', - taskId: 'task-123', - action: 'TaskStarted', - tool: null, - result: 'Success', - approval: null, - inputSummary: 'Audit seed task', - metadata: { surface: 'desktop' }, - }, - ], - }, - }); - - expect(trailReport.payload.sourceProduct).toBe('claw-cowork'); - expect(trailReport.payload.safetySignalCount).toBe(1); - }); - - it('accepts generic artifact.created events for trail-report artifacts', () => { - const created = ArtifactCreatedEventSchema.parse({ - eventId: 'evt_phase3_created', - eventName: 'artifact.created', - eventVersion: 1, - occurredAt: '2026-04-03T14:00:00.000Z', - productId: 'actiontrail', - sourceSurface: 'backend', - userId: 'saravana', - orgId: null, - sessionId: 'sess_phase3', - runId: 'run_phase3_trail', - artifactId: 'art_trail_demo', - actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'evt_cowork_export', - parentEventId: 'evt_cowork_export', - }, - payload: { - artifactType: 'trail-report', - title: 'Cowork audit report for task task-123', - status: 'recorded', - }, - }); - - expect(created.payload.artifactType).toBe('trail-report'); - }); -}); diff --git a/vendor/bytelyst/events/src/ecosystem.ts b/vendor/bytelyst/events/src/ecosystem.ts deleted file mode 100644 index 178ec87..0000000 --- a/vendor/bytelyst/events/src/ecosystem.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { z } from 'zod'; - -export const EcosystemArtifactTypeSchema = z.enum([ - 'transcript', - 'note', - 'memory', - 'plan', - 'routine', - 'habit', - 'habit-checkin', - 'trail-report', - 'route-session', - 'agent-output', - 'document', - 'digest', -]); - -export const ArtifactLinkRelationSchema = z.enum([ - 'derived-from', - 'summarizes', - 'generated-task', - 'generated-routine', - 'generated-habit', - 'generated-memory', - 'evidence-for', - 'review-of', - 'attached-to', -]); - -export const ArtifactLinkSchema = z.object({ - relation: ArtifactLinkRelationSchema, - targetArtifactId: z.string().min(1), -}); - -export const ArtifactCreatedBySchema = z.object({ - actorType: z.enum(['user', 'agent', 'system', 'mixed']), - actorId: z.string().min(1).nullable(), -}); - -export const ArtifactOwnershipSchema = z.object({ - userId: z.string().min(1), - orgId: z.string().min(1).nullable().optional(), -}); - -export const ArtifactVisibilitySchema = z.object({ - scope: z.enum(['private', 'org', 'shared', 'local-only']), - allowedProducts: z.array(z.string().min(1)).optional(), -}); - -export const ArtifactLineageStepSchema = z.object({ - stepType: z.string().min(1), - productId: z.string().min(1), - actorType: z.enum(['user', 'agent', 'system']), - timestamp: z.string().datetime(), -}); - -export const ArtifactProvenanceSchema = z.object({ - originProductId: z.string().min(1), - originActionId: z.string().min(1).nullable().optional(), - sessionId: z.string().min(1).nullable().optional(), - runId: z.string().min(1).nullable().optional(), - approvalId: z.string().min(1).nullable().optional(), - correlationId: z.string().min(1).nullable().optional(), - lineage: z.array(ArtifactLineageStepSchema).min(1), -}); - -export const BaseArtifactEnvelopeSchema = z.object({ - id: z.string().min(1), - artifactType: EcosystemArtifactTypeSchema, - schemaVersion: z.literal(1), - productId: z.string().min(1), - sourceSurface: z.string().min(1), - title: z.string().min(1).nullable(), - summary: z.string().min(1).nullable(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - createdBy: ArtifactCreatedBySchema, - ownership: ArtifactOwnershipSchema, - visibility: ArtifactVisibilitySchema, - status: z.string().min(1), - tags: z.array(z.string().min(1)), - links: z.array(ArtifactLinkSchema), - provenance: ArtifactProvenanceSchema, - payload: z.record(z.unknown()), -}); - -export const TranscriptPayloadSchema = z.object({ - transcriptText: z.string().min(1), - transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), - language: z.string().min(1), - durationMs: z.number().int().nonnegative(), - segments: z - .array( - z.object({ - speaker: z.string().min(1).nullable().optional(), - startedAtMs: z.number().int().nonnegative(), - endedAtMs: z.number().int().nonnegative(), - text: z.string().min(1), - }) - ) - .default([]), -}); - -export const NotePayloadSchema = z.object({ - noteFormat: z.enum(['markdown', 'plain-text', 'rich-text']), - body: z.string().min(1), - excerpt: z.string().min(1).nullable().optional(), -}); - -export const MemoryPayloadSchema = z.object({ - memoryKind: z.enum(['fact', 'preference', 'person', 'project', 'insight', 'todo']), - text: z.string().min(1), - confidence: z.number().min(0).max(1), - sourceArtifactIds: z.array(z.string().min(1)).min(1), - reviewState: z.enum(['proposed', 'accepted', 'rejected']), -}); - -export const PlanPayloadSchema = z.object({ - weekOf: z.string().min(1), - taskCount: z.number().int().nonnegative(), - scheduledEntryCount: z.number().int().nonnegative(), - totalScheduledMinutes: z.number().int().nonnegative(), - entries: z.array( - z.object({ - taskTitle: z.string().min(1), - scheduledDate: z.string().min(1), - startTime: z.string().min(1), - endTime: z.string().min(1), - durationMinutes: z.number().int().nonnegative(), - flowName: z.string().min(1).nullable(), - zoneName: z.string().min(1).nullable(), - priority: z.string().min(1), - }) - ), -}); - -export const RoutinePayloadSchema = z.object({ - routineId: z.string().min(1), - stepCount: z.number().int().nonnegative(), - totalDurationMinutes: z.number().nonnegative(), - status: z.string().min(1), - isTemplate: z.boolean(), - category: z.string().min(1).nullable().optional(), - steps: z.array( - z.object({ - label: z.string().min(1), - durationMinutes: z.number().nonnegative(), - transition: z.string().min(1), - status: z.string().min(1), - }) - ), -}); - -export const HabitPayloadSchema = z.object({ - habitId: z.string().min(1), - identityId: z.string().min(1), - frequency: z.enum(['daily', 'weekly', 'custom']), - customDays: z.array(z.number().int().min(0).max(6)).optional(), - targetCount: z.number().int().positive(), - reminderTime: z.string().min(1).nullable().optional(), - isActive: z.boolean(), - sourceRoutineId: z.string().min(1), -}); - -export const TrailReportPayloadSchema = z.object({ - sourceProduct: z.literal('claw-cowork'), - sourceTaskId: z.string().min(1).nullable().optional(), - generatedFrom: z.enum(['audit-export-json', 'audit-query-json']), - reportGeneratedAt: z.string().datetime(), - actionCount: z.number().int().nonnegative(), - toolCallCount: z.number().int().nonnegative(), - approvalCount: z.number().int().nonnegative(), - failureCount: z.number().int().nonnegative(), - safetySignalCount: z.number().int().nonnegative(), - tasks: z.array(z.string().min(1)), - actionBreakdown: z.array( - z.object({ - action: z.string().min(1), - count: z.number().int().positive(), - }) - ), - entries: z.array( - z.object({ - timestamp: z.string().datetime(), - taskId: z.string().min(1).nullable(), - action: z.string().min(1), - tool: z.string().min(1).nullable().optional(), - result: z.string().min(1).nullable().optional(), - approval: z.string().min(1).nullable().optional(), - inputSummary: z.string().min(1).nullable().optional(), - metadata: z.record(z.unknown()).nullable().optional(), - }) - ), -}); - -export const TranscriptArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('transcript'), - payload: TranscriptPayloadSchema, -}); - -export const NoteArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('note'), - payload: NotePayloadSchema, -}); - -export const MemoryArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('memory'), - payload: MemoryPayloadSchema, -}); - -export const PlanArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('plan'), - payload: PlanPayloadSchema, -}); - -export const RoutineArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('routine'), - payload: RoutinePayloadSchema, -}); - -export const HabitArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('habit'), - payload: HabitPayloadSchema, -}); - -export const TrailReportArtifactEnvelopeSchema = BaseArtifactEnvelopeSchema.extend({ - artifactType: z.literal('trail-report'), - payload: TrailReportPayloadSchema, -}); - -export const Phase1ArtifactEnvelopeSchema = z.discriminatedUnion('artifactType', [ - TranscriptArtifactEnvelopeSchema, - NoteArtifactEnvelopeSchema, - MemoryArtifactEnvelopeSchema, -]); - -export const EcosystemEventActorSchema = z.object({ - actorType: z.enum(['user', 'agent', 'system', 'device']), - actorId: z.string().min(1).nullable().optional(), -}); - -export const EcosystemEventTraceSchema = z.object({ - correlationId: z.string().min(1).nullable(), - causationId: z.string().min(1).nullable(), - parentEventId: z.string().min(1).nullable(), -}); - -export const BaseEcosystemEventSchema = z.object({ - eventId: z.string().min(1), - eventName: z.string().min(1), - eventVersion: z.literal(1), - occurredAt: z.string().datetime(), - productId: z.string().min(1), - sourceSurface: z.string().min(1), - userId: z.string().min(1).nullable().optional(), - orgId: z.string().min(1).nullable().optional(), - sessionId: z.string().min(1).nullable().optional(), - runId: z.string().min(1).nullable().optional(), - artifactId: z.string().min(1).nullable().optional(), - actor: EcosystemEventActorSchema, - trace: EcosystemEventTraceSchema, - payload: z.record(z.unknown()), -}); - -export const CaptureTranscriptCreatedPayloadSchema = z.object({ - artifactId: z.string().min(1), - durationMs: z.number().int().nonnegative(), - language: z.string().min(1), - transcriptSource: z.enum(['microphone', 'upload', 'call', 'browser', 'other']), -}); - -export const ArtifactCreatedPayloadSchema = z.object({ - artifactType: z.enum([ - 'transcript', - 'note', - 'memory', - 'plan', - 'routine', - 'habit', - 'trail-report', - ]), - title: z.string().min(1).nullable(), - status: z.string().min(1), -}); - -export const ArtifactLinkedPayloadSchema = z.object({ - sourceArtifactId: z.string().min(1), - targetArtifactId: z.string().min(1), - relation: z.enum([ - 'summarizes', - 'generated-memory', - 'generated-routine', - 'generated-habit', - 'derived-from', - ]), -}); - -export const MemoryEntryCreatedPayloadSchema = z.object({ - artifactId: z.string().min(1), - memoryKind: MemoryPayloadSchema.shape.memoryKind, - reviewState: MemoryPayloadSchema.shape.reviewState, - confidence: MemoryPayloadSchema.shape.confidence, - sourceArtifactIds: z.array(z.string().min(1)).min(1), -}); - -export const CaptureTranscriptCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('capture.transcript.created'), - payload: CaptureTranscriptCreatedPayloadSchema, -}); - -export const ArtifactCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('artifact.created'), - payload: ArtifactCreatedPayloadSchema, -}); - -export const ArtifactLinkedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('artifact.linked'), - payload: ArtifactLinkedPayloadSchema, -}); - -export const MemoryEntryCreatedEventSchema = BaseEcosystemEventSchema.extend({ - eventName: z.literal('memory.entry.created'), - payload: MemoryEntryCreatedPayloadSchema, -}); - -export const Phase1EcosystemEventSchema = z.discriminatedUnion('eventName', [ - CaptureTranscriptCreatedEventSchema, - ArtifactCreatedEventSchema, - ArtifactLinkedEventSchema, - MemoryEntryCreatedEventSchema, -]); - -export const Phase1EcosystemEventSchemas = { - 'capture.transcript.created': CaptureTranscriptCreatedEventSchema, - 'artifact.created': ArtifactCreatedEventSchema, - 'artifact.linked': ArtifactLinkedEventSchema, - 'memory.entry.created': MemoryEntryCreatedEventSchema, -} as const; - -export type ArtifactEnvelope = z.infer; -export type TranscriptArtifactEnvelope = z.infer; -export type NoteArtifactEnvelope = z.infer; -export type MemoryArtifactEnvelope = z.infer; -export type PlanArtifactEnvelope = z.infer; -export type RoutineArtifactEnvelope = z.infer; -export type HabitArtifactEnvelope = z.infer; -export type Phase1ArtifactEnvelope = z.infer; -export type EcosystemEvent = z.infer; -export type Phase1EcosystemEvent = z.infer; diff --git a/vendor/bytelyst/events/src/index.ts b/vendor/bytelyst/events/src/index.ts deleted file mode 100644 index 2c22a07..0000000 --- a/vendor/bytelyst/events/src/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -export { EventBus } from './memory.js'; -export type { EmitResult, EmitError } from './memory.js'; -export { DurableEventBus } from './durable.js'; -export type { DurableEventBusOptions } from './durable.js'; -export { PlatformEventSchemas } from './types.js'; -export { - AgentActionLogSchema, - AgentCheckpointSchema, - AgentApprovalCheckpointSchema, - AgentDispatchContextSchema, - AgentDispatchRequestSchema, - AgentRunSchema, - AgentSessionSchema, - AgentTaskSchema, - AgentTodoSchema, -} from './agent-runtime.js'; -export { - ArtifactCreatedBySchema, - ArtifactLineageStepSchema, - ArtifactLinkRelationSchema, - ArtifactLinkSchema, - ArtifactOwnershipSchema, - ArtifactProvenanceSchema, - ArtifactVisibilitySchema, - BaseArtifactEnvelopeSchema, - BaseEcosystemEventSchema, - CaptureTranscriptCreatedEventSchema, - CaptureTranscriptCreatedPayloadSchema, - EcosystemArtifactTypeSchema, - EcosystemEventActorSchema, - EcosystemEventTraceSchema, - MemoryArtifactEnvelopeSchema, - MemoryEntryCreatedEventSchema, - MemoryEntryCreatedPayloadSchema, - MemoryPayloadSchema, - NoteArtifactEnvelopeSchema, - NotePayloadSchema, - Phase1ArtifactEnvelopeSchema, - Phase1EcosystemEventSchema, - Phase1EcosystemEventSchemas, - TranscriptArtifactEnvelopeSchema, - TranscriptPayloadSchema, - ArtifactCreatedEventSchema, - ArtifactCreatedPayloadSchema, - ArtifactLinkedEventSchema, - ArtifactLinkedPayloadSchema, -} from './ecosystem.js'; -export { - buildTimelineItem, - buildTimelineItems, - TimelineItemSchema, - TimelineVisibilitySchema, -} from './timeline.js'; -export type { - AgentActionLog, - AgentCheckpoint, - AgentApprovalCheckpoint, - AgentDispatchContext, - AgentDispatchRequest, - AgentRun, - AgentSession, - AgentTask, - AgentTodo, -} from './agent-runtime.js'; -export type { - PlatformEventName, - PlatformEventPayload, - PlatformEvent, - EventHandler, - EventSubscription, -} from './types.js'; -export type { - EcosystemEvent, - MemoryArtifactEnvelope, - NoteArtifactEnvelope, - Phase1ArtifactEnvelope, - Phase1EcosystemEvent, - TranscriptArtifactEnvelope, -} from './ecosystem.js'; -export type { TimelineItem } from './timeline.js'; diff --git a/vendor/bytelyst/events/src/memory.test.ts b/vendor/bytelyst/events/src/memory.test.ts deleted file mode 100644 index 87567d0..0000000 --- a/vendor/bytelyst/events/src/memory.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { EventBus } from './memory.js'; -import type { PlatformEvent } from './types.js'; - -describe('EventBus', () => { - let bus: EventBus; - - beforeEach(() => { - bus = new EventBus(); - }); - - describe('on / emit', () => { - it('should deliver events to registered handlers', async () => { - const handler = vi.fn(); - bus.on('user.created', handler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'test@example.com', - plan: 'free', - productId: 'lysnrai', - }); - - expect(handler).toHaveBeenCalledOnce(); - expect(handler.mock.calls[0][0]).toMatchObject({ - type: 'user.created', - payload: { userId: 'u1', email: 'test@example.com' }, - }); - }); - - it('should include event id and timestamp', async () => { - let received: PlatformEvent<'user.created'> | undefined; - bus.on('user.created', e => { - received = e; - }); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(received).toBeDefined(); - expect(received!.id).toBeTruthy(); - expect(received!.timestamp).toBeTruthy(); - }); - - it('should deliver to multiple handlers', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(); - const h3 = vi.fn(); - bus.on('user.created', h1); - bus.on('user.created', h2); - bus.on('user.created', h3); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(h1).toHaveBeenCalledOnce(); - expect(h2).toHaveBeenCalledOnce(); - expect(h3).toHaveBeenCalledOnce(); - }); - - it('should not deliver to handlers of different event types', async () => { - const userHandler = vi.fn(); - const paymentHandler = vi.fn(); - bus.on('user.created', userHandler); - bus.on('payment.succeeded', paymentHandler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(userHandler).toHaveBeenCalledOnce(); - expect(paymentHandler).not.toHaveBeenCalled(); - }); - - it('should return result with zero handlers when no subscribers', async () => { - const result = await bus.emit('user.deleted', { - userId: 'u1', - productId: 'test', - }); - - expect(result.handlerCount).toBe(0); - expect(result.errors).toHaveLength(0); - expect(result.eventId).toBeTruthy(); - }); - }); - - describe('error isolation', () => { - it('should not block other handlers when one throws', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(() => { - throw new Error('handler crash'); - }); - const h3 = vi.fn(); - - bus.on('user.created', h1); - bus.on('user.created', h2); - bus.on('user.created', h3); - - const result = await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - - expect(h1).toHaveBeenCalledOnce(); - expect(h2).toHaveBeenCalledOnce(); - expect(h3).toHaveBeenCalledOnce(); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toBe('handler crash'); - }); - - it('should handle async handler rejection', async () => { - bus.on('payment.failed', async () => { - throw new Error('async fail'); - }); - - const result = await bus.emit('payment.failed', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 999, - retryCount: 1, - productId: 'test', - }); - - expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toBe('async fail'); - }); - }); - - describe('unsubscribe', () => { - it('should stop delivering events after unsubscribe', async () => { - const handler = vi.fn(); - const sub = bus.on('user.created', handler); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - expect(handler).toHaveBeenCalledOnce(); - - sub.unsubscribe(); - - await bus.emit('user.created', { - userId: 'u2', - email: 'b@c.com', - plan: 'pro', - productId: 'test', - }); - expect(handler).toHaveBeenCalledOnce(); // still 1, not 2 - }); - - it('should return subscription metadata', () => { - const sub = bus.on('flag.toggled', () => {}); - expect(sub.id).toBeTruthy(); - expect(sub.eventType).toBe('flag.toggled'); - expect(typeof sub.unsubscribe).toBe('function'); - }); - }); - - describe('clear', () => { - it('should remove all handlers for a specific event type', async () => { - const h1 = vi.fn(); - const h2 = vi.fn(); - bus.on('user.created', h1); - bus.on('payment.succeeded', h2); - - bus.clear('user.created'); - - await bus.emit('user.created', { - userId: 'u1', - email: 'a@b.com', - plan: 'free', - productId: 'test', - }); - await bus.emit('payment.succeeded', { - invoiceId: 'inv_1', - userId: 'u1', - amount: 100, - currency: 'usd', - productId: 'test', - }); - - expect(h1).not.toHaveBeenCalled(); - expect(h2).toHaveBeenCalledOnce(); - }); - - it('should remove all handlers when called without args', () => { - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - bus.on('flag.toggled', () => {}); - - expect(bus.eventTypes().length).toBe(3); - bus.clear(); - expect(bus.eventTypes().length).toBe(0); - }); - }); - - describe('listenerCount / eventTypes', () => { - it('should count handlers per event type', () => { - bus.on('user.created', () => {}); - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - - expect(bus.listenerCount('user.created')).toBe(2); - expect(bus.listenerCount('payment.failed')).toBe(1); - expect(bus.listenerCount('user.deleted')).toBe(0); - }); - - it('should list event types with handlers', () => { - bus.on('user.created', () => {}); - bus.on('payment.failed', () => {}); - - const types = bus.eventTypes(); - expect(types).toContain('user.created'); - expect(types).toContain('payment.failed'); - expect(types).not.toContain('user.deleted'); - }); - }); - - describe('source option', () => { - it('should pass source through to handlers', async () => { - let received: PlatformEvent<'user.created'> | undefined; - bus.on('user.created', e => { - received = e; - }); - - await bus.emit( - 'user.created', - { userId: 'u1', email: 'a@b.com', plan: 'free', productId: 'test' }, - { source: 'auth-module' } - ); - - expect(received?.source).toBe('auth-module'); - }); - }); -}); diff --git a/vendor/bytelyst/events/src/memory.ts b/vendor/bytelyst/events/src/memory.ts deleted file mode 100644 index 0c419b6..0000000 --- a/vendor/bytelyst/events/src/memory.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - PlatformEventName, - PlatformEventPayload, - PlatformEvent, - EventHandler, - EventSubscription, -} from './types.js'; - -// ── In-Memory Event Bus ────────────────────────────────────── -// Phase 1 implementation: typed in-process pub/sub with error isolation. -// Handlers run concurrently via Promise.allSettled — a failing handler -// never blocks other handlers or the emitter. - -export class EventBus { - private handlers = new Map }>>(); - private subscriptionCounter = 0; - - /** - * Subscribe to a specific event type. - * Returns an EventSubscription with an `unsubscribe()` method. - */ - on(eventType: T, handler: EventHandler): EventSubscription { - const id = `sub_${++this.subscriptionCounter}`; - - if (!this.handlers.has(eventType)) { - this.handlers.set(eventType, new Set()); - } - - const entry = { id, fn: handler as EventHandler }; - this.handlers.get(eventType)!.add(entry); - - return { - id, - eventType, - unsubscribe: () => { - this.handlers.get(eventType)?.delete(entry); - }, - }; - } - - /** - * Emit an event to all registered handlers for that event type. - * Handlers run concurrently. Errors are collected and returned, - * never thrown — the emitter is never blocked. - */ - async emit( - eventType: T, - payload: PlatformEventPayload, - options?: { source?: string } - ): Promise { - const event: PlatformEvent = { - id: crypto.randomUUID(), - type: eventType, - payload, - timestamp: new Date().toISOString(), - source: options?.source, - }; - - const handlers = this.handlers.get(eventType); - if (!handlers || handlers.size === 0) { - return { eventId: event.id, handlerCount: 0, errors: [] }; - } - - const results = await Promise.allSettled( - Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent)) - ); - - const errors: EmitError[] = []; - for (const result of results) { - if (result.status === 'rejected') { - errors.push({ - eventType, - eventId: event.id, - error: result.reason instanceof Error ? result.reason.message : String(result.reason), - }); - } - } - - return { eventId: event.id, handlerCount: handlers.size, errors }; - } - - /** - * Remove all handlers for a specific event type, or all handlers if no type given. - */ - clear(eventType?: PlatformEventName): void { - if (eventType) { - this.handlers.delete(eventType); - } else { - this.handlers.clear(); - } - } - - /** - * Get count of registered handlers for a specific event type. - */ - listenerCount(eventType: PlatformEventName): number { - return this.handlers.get(eventType)?.size ?? 0; - } - - /** - * Get all event types that have at least one handler registered. - */ - eventTypes(): PlatformEventName[] { - return Array.from(this.handlers.entries()) - .filter(([, set]) => set.size > 0) - .map(([type]) => type as PlatformEventName); - } -} - -// ── Result Types ───────────────────────────────────────────── - -export interface EmitResult { - eventId: string; - handlerCount: number; - errors: EmitError[]; -} - -export interface EmitError { - eventType: string; - eventId: string; - error: string; -} diff --git a/vendor/bytelyst/events/src/timeline.test.ts b/vendor/bytelyst/events/src/timeline.test.ts deleted file mode 100644 index 4f444c7..0000000 --- a/vendor/bytelyst/events/src/timeline.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildTimelineItems, TimelineItemSchema } from './timeline.js'; - -describe('timeline contract baseline', () => { - it('builds unified timeline items across phases 1 to 3', () => { - const items = buildTimelineItems([ - { - eventId: 'evt_capture_1', - eventName: 'capture.transcript.created', - occurredAt: '2026-04-03T12:00:00.000Z', - productId: 'lysnrai', - artifactId: 'art_transcript_1', - actor: { actorType: 'user', actorId: 'saravana' }, - trace: { correlationId: 'corr_phase1', causationId: null, parentEventId: null }, - payload: { durationMs: 42150, transcriptSource: 'microphone' }, - }, - { - eventId: 'evt_plan_1', - eventName: 'artifact.created', - occurredAt: '2026-04-03T13:00:00.000Z', - productId: 'flowmonk', - artifactId: 'art_plan_1', - actor: { actorType: 'agent', actorId: 'phase2-plan-exporter' }, - trace: { correlationId: 'corr_phase2', causationId: null, parentEventId: null }, - payload: { artifactType: 'plan', title: 'FlowMonk weekly plan', status: 'draft' }, - }, - { - eventId: 'evt_trail_1', - eventName: 'artifact.created', - occurredAt: '2026-04-03T14:00:00.000Z', - productId: 'actiontrail', - artifactId: 'art_trail_1', - actor: { actorType: 'agent', actorId: 'phase3-audit-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'task_phase3', - parentEventId: 'task_phase3', - }, - payload: { - artifactType: 'trail-report', - title: 'Cowork audit report for task task_phase3', - status: 'recorded', - }, - }, - { - eventId: 'evt_memory_1', - eventName: 'memory.entry.created', - occurredAt: '2026-04-03T14:10:00.000Z', - productId: 'mindlyst', - artifactId: 'art_memory_1', - actor: { actorType: 'agent', actorId: 'phase3-audit-note-importer' }, - trace: { - correlationId: 'corr_phase3', - causationId: 'evt_note_linked_1', - parentEventId: 'evt_note_linked_1', - }, - payload: { - memoryKind: 'insight', - sourceArtifactIds: ['art_note_1', 'art_trail_1'], - }, - }, - ]); - - expect(items.map(item => item.eventName)).toEqual([ - 'memory.entry.created', - 'artifact.created', - 'artifact.created', - 'capture.transcript.created', - ]); - expect(items[0]?.title).toBe('Insight memory proposed'); - expect(items[1]?.title).toBe('Cowork audit report for task task_phase3'); - expect(items[2]?.summary).toBe('plan status: draft'); - expect(items[3]?.summary).toContain('microphone transcript captured'); - expect(items[0]?.relatedEventIds).toEqual(['evt_note_linked_1']); - }); - - it('exposes a stable timeline item schema', () => { - const item = TimelineItemSchema.parse({ - itemId: 'timeline_evt_1', - occurredAt: '2026-04-03T14:10:00.000Z', - eventName: 'memory.entry.created', - productId: 'mindlyst', - title: 'Insight memory proposed', - summary: '2 source artifacts linked', - artifactRefs: ['art_memory_1'], - relatedEventIds: ['evt_note_linked_1'], - actorType: 'agent', - visibility: 'private', - correlationId: 'corr_phase3', - }); - - expect(item.visibility).toBe('private'); - expect(item.relatedEventIds).toHaveLength(1); - }); -}); diff --git a/vendor/bytelyst/events/src/timeline.ts b/vendor/bytelyst/events/src/timeline.ts deleted file mode 100644 index ad9568f..0000000 --- a/vendor/bytelyst/events/src/timeline.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { z } from 'zod'; -import type { EcosystemEvent } from './ecosystem.js'; - -export const TimelineVisibilitySchema = z.enum(['private', 'org', 'shared', 'local-only']); - -export const TimelineItemSchema = z.object({ - itemId: z.string().min(1), - occurredAt: z.string().datetime(), - eventName: z.string().min(1), - productId: z.string().min(1), - title: z.string().min(1), - summary: z.string().nullable().optional(), - artifactRefs: z.array(z.string().min(1)), - relatedEventIds: z.array(z.string().min(1)), - actorType: z.enum(['user', 'agent', 'system', 'device']), - visibility: TimelineVisibilitySchema, - correlationId: z.string().min(1).nullable().optional(), -}); - -export type TimelineItem = z.infer; - -type EventLike = Pick< - EcosystemEvent, - | 'eventId' - | 'eventName' - | 'occurredAt' - | 'productId' - | 'artifactId' - | 'actor' - | 'trace' - | 'payload' ->; - -function titleForEvent(event: EventLike): string { - if (event.eventName === 'capture.transcript.created') { - return 'Transcript captured'; - } - if (event.eventName === 'memory.entry.created') { - const kind = typeof event.payload.memoryKind === 'string' ? event.payload.memoryKind : 'memory'; - return `${capitalize(kind)} memory proposed`; - } - if (event.eventName === 'artifact.created') { - const type = - typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; - const title = - typeof event.payload.title === 'string' && event.payload.title ? event.payload.title : null; - return title ? title : `${capitalize(type)} created`; - } - if (event.eventName === 'artifact.linked') { - const relation = typeof event.payload.relation === 'string' ? event.payload.relation : 'linked'; - return `Artifact ${relation}`; - } - return event.eventName; -} - -function summaryForEvent(event: EventLike): string | null { - if (event.eventName === 'capture.transcript.created') { - const duration = - typeof event.payload.durationMs === 'number' - ? `${event.payload.durationMs}ms` - : 'unknown duration'; - const source = - typeof event.payload.transcriptSource === 'string' - ? event.payload.transcriptSource - : 'unknown source'; - return `${source} transcript captured (${duration})`; - } - if (event.eventName === 'memory.entry.created') { - const sources = Array.isArray(event.payload.sourceArtifactIds) - ? event.payload.sourceArtifactIds.length - : 0; - return `${sources} source artifact${sources === 1 ? '' : 's'} linked`; - } - if (event.eventName === 'artifact.created') { - const type = - typeof event.payload.artifactType === 'string' ? event.payload.artifactType : 'artifact'; - const status = typeof event.payload.status === 'string' ? event.payload.status : 'created'; - return `${type} status: ${status}`; - } - if (event.eventName === 'artifact.linked') { - const target = - typeof event.payload.targetArtifactId === 'string' - ? event.payload.targetArtifactId - : 'unknown target'; - return `Linked to ${target}`; - } - return null; -} - -function relatedEventIdsForEvent(event: EventLike): string[] { - return Array.from( - new Set( - [event.trace.parentEventId, event.trace.causationId].filter( - (value): value is string => typeof value === 'string' && value.length > 0 - ) - ) - ); -} - -export function buildTimelineItem(event: EventLike): TimelineItem { - return TimelineItemSchema.parse({ - itemId: `timeline_${event.eventId}`, - occurredAt: event.occurredAt, - eventName: event.eventName, - productId: event.productId, - title: titleForEvent(event), - summary: summaryForEvent(event), - artifactRefs: [event.artifactId].filter((value): value is string => Boolean(value)), - relatedEventIds: relatedEventIdsForEvent(event), - actorType: event.actor.actorType, - visibility: 'private', - correlationId: event.trace.correlationId ?? null, - }); -} - -export function buildTimelineItems(events: EventLike[]): TimelineItem[] { - return events - .map(buildTimelineItem) - .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt)); -} - -function capitalize(value: string): string { - return value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; -} diff --git a/vendor/bytelyst/events/src/types.ts b/vendor/bytelyst/events/src/types.ts deleted file mode 100644 index f59677b..0000000 --- a/vendor/bytelyst/events/src/types.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { z } from 'zod'; - -// ── Platform Event Schemas ─────────────────────────────────── -// Each event type has a Zod schema for payload validation. -// Handlers receive typed payloads matching these schemas. - -export const PlatformEventSchemas = { - // Auth events - 'user.created': z.object({ - userId: z.string(), - email: z.string(), - plan: z.string(), - productId: z.string(), - }), - 'user.deleted': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - }), - 'user.email_verified': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - }), - 'user.password_reset': z.object({ - userId: z.string(), - email: z.string(), - resetToken: z.string(), - displayName: z.string().optional(), - productId: z.string(), - }), - 'user.email_verification_requested': z.object({ - userId: z.string(), - email: z.string(), - verificationToken: z.string(), - displayName: z.string().optional(), - productId: z.string(), - }), - - // SmartAuth events - 'auth.account_locked': z.object({ - userId: z.string(), - email: z.string(), - productId: z.string(), - lockedUntil: z.string(), - failedAttempts: z.number(), - }), - 'auth.oauth_linked': z.object({ - userId: z.string(), - provider: z.string(), - providerEmail: z.string(), - productId: z.string(), - }), - 'auth.oauth_unlinked': z.object({ - userId: z.string(), - provider: z.string(), - productId: z.string(), - }), - 'auth.membership_provisioned': z.object({ - userId: z.string(), - productId: z.string(), - plan: z.string(), - role: z.string(), - }), - 'auth.account_merged': z.object({ - primaryUserId: z.string(), - secondaryUserId: z.string(), - productId: z.string(), - }), - 'auth.magic_link_requested': z.object({ - userId: z.string(), - email: z.string(), - token: z.string(), - productId: z.string(), - expiresAt: z.string(), - }), - - // Subscription events - 'subscription.created': z.object({ - subscriptionId: z.string(), - userId: z.string(), - plan: z.string(), - status: z.string(), - productId: z.string(), - }), - 'subscription.changed': z.object({ - subscriptionId: z.string(), - userId: z.string(), - oldPlan: z.string(), - newPlan: z.string(), - productId: z.string(), - }), - 'subscription.canceled': z.object({ - subscriptionId: z.string(), - userId: z.string(), - reason: z.string().optional(), - productId: z.string(), - }), - 'subscription.trial_expiring': z.object({ - subscriptionId: z.string(), - userId: z.string(), - expiresAt: z.string(), - productId: z.string(), - }), - 'subscription.trial_expired': z.object({ - subscriptionId: z.string(), - userId: z.string(), - productId: z.string(), - }), - - // Payment events - 'payment.succeeded': z.object({ - invoiceId: z.string(), - userId: z.string(), - amount: z.number(), - currency: z.string(), - productId: z.string(), - }), - 'payment.failed': z.object({ - invoiceId: z.string(), - userId: z.string(), - amount: z.number(), - currency: z.string().default('usd'), - retryCount: z.number(), - productId: z.string(), - }), - - // Growth events - 'invitation.redeemed': z.object({ - invitationId: z.string(), - userId: z.string(), - productId: z.string(), - }), - 'referral.completed': z.object({ - referralId: z.string(), - referrerId: z.string(), - referredId: z.string(), - productId: z.string(), - }), - 'waitlist.joined': z.object({ - email: z.string(), - position: z.number(), - productId: z.string(), - }), - - // Feature flags - 'flag.toggled': z.object({ - flagId: z.string(), - enabled: z.boolean(), - percentage: z.number().optional(), - productId: z.string(), - flagKey: z.string().optional(), - actor: z.string().optional(), - }), - 'flag.created': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - }), - 'flag.updated': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - changes: z.array(z.string()).optional(), - }), - 'flag.deleted': z.object({ - productId: z.string(), - flagKey: z.string(), - actor: z.string().optional(), - }), - 'flag.kill_switch': z.object({ - productId: z.string(), - disabled: z.array(z.string()), - actor: z.string().optional(), - }), - - // License events - 'license.activated': z.object({ - licenseId: z.string(), - userId: z.string(), - deviceId: z.string().optional(), - productId: z.string(), - }), - 'license.expired': z.object({ - licenseId: z.string(), - userId: z.string(), - productId: z.string(), - }), - - // Job events - 'job.completed': z.object({ - jobName: z.string(), - runId: z.string(), - durationMs: z.number(), - productId: z.string(), - }), - 'job.failed': z.object({ - jobName: z.string(), - runId: z.string(), - error: z.string(), - productId: z.string(), - }), - - // Diagnostics events - 'diagnostics.session.created': z.object({ - sessionId: z.string(), - productId: z.string(), - targetUserId: z.string().optional(), - targetAnonymousId: z.string().optional(), - targetDeviceId: z.string().optional(), - createdBy: z.string(), - }), - 'diagnostics.session.started': z.object({ - sessionId: z.string(), - productId: z.string(), - startedAt: z.string(), - }), - 'diagnostics.session.updated': z.object({ - sessionId: z.string(), - productId: z.string(), - changes: z.record(z.unknown()), - updatedBy: z.string(), - }), - 'diagnostics.session.cancelled': z.object({ - sessionId: z.string(), - productId: z.string(), - reason: z.string().optional(), - cancelledBy: z.string(), - }), - 'diagnostics.session.completed': z.object({ - sessionId: z.string(), - productId: z.string(), - stats: z.object({ - logCount: z.number(), - traceCount: z.number(), - screenshotCount: z.number(), - }), - endedAt: z.string(), - }), - 'diagnostics.session.expired': z.object({ - sessionId: z.string(), - productId: z.string(), - expiredAt: z.string(), - }), - 'diagnostics.ingest.fatal': z.object({ - sessionId: z.string(), - productId: z.string(), - logEntry: z.record(z.unknown()).optional(), - timestamp: z.string(), - }), - 'diagnostics.screenshot.captured': z.object({ - sessionId: z.string(), - productId: z.string(), - screenshotId: z.string(), - trigger: z.enum(['manual', 'error', 'interval', 'user_request']), - }), - - // Delivery events - 'delivery.email.requested': z.object({ - userId: z.string(), - productId: z.string(), - templateId: z.string(), - context: z.record(z.unknown()).optional(), - }), - - // Notifications events - 'notifications.push.requested': z.object({ - userId: z.string(), - productId: z.string(), - title: z.string(), - body: z.string(), - data: z.record(z.unknown()).optional(), - }), - 'notifications.inapp.create': z.object({ - userId: z.string(), - productId: z.string(), - title: z.string(), - content: z.string(), - priority: z.enum(['high', 'normal', 'low']).optional(), - }), - - // Integration events - 'integrations.slack.notify': z.object({ - channel: z.string(), - message: z.record(z.unknown()), - }), - - // Predictive analytics events - 'predictive.campaign.triggered': z.object({ - campaignId: z.string(), - userId: z.string(), - productId: z.string(), - riskSegment: z.string(), - channels: z.array(z.string()), - }), -} as const; - -// ── Derived Types ──────────────────────────────────────────── - -export type PlatformEventName = keyof typeof PlatformEventSchemas; - -export type PlatformEventPayload = z.infer< - (typeof PlatformEventSchemas)[T] ->; - -export interface PlatformEvent { - id: string; - type: T; - payload: PlatformEventPayload; - timestamp: string; - source?: string; -} - -export type EventHandler = ( - event: PlatformEvent -) => void | Promise; - -export interface EventSubscription { - id: string; - eventType: PlatformEventName; - unsubscribe: () => void; -} diff --git a/vendor/bytelyst/events/tsconfig.json b/vendor/bytelyst/events/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/events/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/extraction/package.json b/vendor/bytelyst/extraction/package.json deleted file mode 100644 index f83be5c..0000000 --- a/vendor/bytelyst/extraction/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/extraction", - "version": "0.1.5", - "type": "module", - "description": "Shared types and client for the extraction service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts b/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts deleted file mode 100644 index 5c15c98..0000000 --- a/vendor/bytelyst/extraction/src/__tests__/extraction.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Tests for @bytelyst/extraction package — client factory + types. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createExtractionClient } from '../client.js'; -import type { - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - ExtractionTask, - ExtractionClientConfig, - ExtractionEntity, - ExtractionExample, -} from '../types.js'; - -// ── Mock @bytelyst/api-client ────────────────────────────────── - -const mockApiFetch = vi.fn(); - -vi.mock('@bytelyst/api-client', () => ({ - createApiClient: vi.fn(() => ({ - fetch: mockApiFetch, - })), -})); - -describe('createExtractionClient', () => { - beforeEach(() => { - mockApiFetch.mockReset(); - }); - - it('returns an object with extract, extractBatch, listTasks, getTask', () => { - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - expect(typeof client.extract).toBe('function'); - expect(typeof client.extractBatch).toBe('function'); - expect(typeof client.listTasks).toBe('function'); - expect(typeof client.getTask).toBe('function'); - }); - - describe('extract', () => { - it('calls POST /api/extract with correct body', async () => { - const mockResponse: ExtractResponse = { - extractions: [{ extraction_class: 'person', extraction_text: 'John' }], - metadata: { modelId: 'gemini-1.5', durationMs: 150, charCount: 35 }, - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req: ExtractRequest = { - text: 'John said we should ship by Friday.', - taskId: 'transcript-extraction', - }; - - const result = await client.extract(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result).toEqual(mockResponse); - }); - - it('passes optional fields in request', async () => { - mockApiFetch.mockResolvedValue({ extractions: [], metadata: {} }); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - const req: ExtractRequest = { - text: 'Hello world', - taskPrompt: 'Extract entities', - modelId: 'gpt-4', - productId: 'lysnrai', - options: { extractionPasses: 2, maxWorkers: 4, maxCharBuffer: 1000 }, - examples: [ - { text: 'Hi Bob', extractions: [{ extraction_class: 'person', extraction_text: 'Bob' }] }, - ], - }; - - await client.extract(req); - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - }); - - it('propagates errors from api client', async () => { - mockApiFetch.mockRejectedValue(new Error('Forbidden')); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await expect(client.extract({ text: 'test' })).rejects.toThrow('Forbidden'); - }); - }); - - describe('extractBatch', () => { - it('calls POST /api/extract/batch with correct body', async () => { - const mockResponse: BatchExtractResponse = { - results: [{ extractions: [], metadata: { modelId: 'test', durationMs: 10, charCount: 5 } }], - requestId: 'req-123', - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req: BatchExtractRequest = { - inputs: [ - { text: 'First document', taskId: 'triage' }, - { text: 'Second document', taskPrompt: 'Extract names' }, - ], - modelId: 'gemini-1.5', - productId: 'mindlyst', - }; - - const result = await client.extractBatch(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/extract/batch', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result).toEqual(mockResponse); - }); - }); - - describe('listTasks', () => { - it('calls GET /api/tasks without productId', async () => { - const tasks: ExtractionTask[] = [ - { - id: 'triage', - name: 'Triage Extraction', - prompt: 'Extract entities', - classes: ['person', 'date'], - builtIn: true, - productId: 'lysnrai', - }, - ]; - mockApiFetch.mockResolvedValue(tasks); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const result = await client.listTasks(); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks'); - expect(result).toEqual(tasks); - }); - - it('appends productId query param when provided', async () => { - mockApiFetch.mockResolvedValue([]); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.listTasks('mindlyst'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=mindlyst'); - }); - - it('encodes special characters in productId', async () => { - mockApiFetch.mockResolvedValue([]); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.listTasks('my product'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks?productId=my%20product'); - }); - }); - - describe('getTask', () => { - it('calls GET /api/tasks/:id without productId', async () => { - const task: ExtractionTask = { - id: 'triage', - name: 'Triage', - prompt: 'Extract', - classes: ['person'], - builtIn: true, - productId: 'lysnrai', - }; - mockApiFetch.mockResolvedValue(task); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const result = await client.getTask('triage'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/triage'); - expect(result).toEqual(task); - }); - - it('appends productId query param when provided', async () => { - mockApiFetch.mockResolvedValue({}); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.getTask('my-task', 'mindlyst'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/my-task?productId=mindlyst'); - }); - - it('encodes special characters in task id', async () => { - mockApiFetch.mockResolvedValue({}); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await client.getTask('task with spaces'); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/tasks/task%20with%20spaces'); - }); - }); - - describe('transcribe', () => { - it('calls POST /api/transcribe with correct body', async () => { - const mockResponse = { - text: 'Hello, this is a test recording.', - language: 'en', - durationSeconds: 5.2, - model: 'whisper-1', - durationMs: 1200, - }; - mockApiFetch.mockResolvedValue(mockResponse); - - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - const req = { - audioUrl: 'https://blob.example.com/audio.mp3', - language: 'en', - productId: 'notelett', - }; - - const result = await client.transcribe(req); - - expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - expect(result.text).toBe('Hello, this is a test recording.'); - expect(result.language).toBe('en'); - expect(result.durationSeconds).toBe(5.2); - }); - - it('passes optional model and prompt fields', async () => { - mockApiFetch.mockResolvedValue({ - text: 'test', - language: null, - durationSeconds: null, - model: 'whisper-1', - durationMs: 100, - }); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - const req = { - audioUrl: 'https://blob.example.com/meeting.wav', - model: 'whisper-1', - prompt: 'Technical meeting about software architecture.', - responseFormat: 'verbose_json' as const, - }; - - await client.transcribe(req); - expect(mockApiFetch).toHaveBeenCalledWith('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - }); - - it('propagates errors from api client', async () => { - mockApiFetch.mockRejectedValue(new Error('Service unavailable')); - const client = createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - await expect( - client.transcribe({ audioUrl: 'https://blob.example.com/audio.mp3' }) - ).rejects.toThrow('Service unavailable'); - }); - }); - - describe('config options', () => { - it('passes getToken to createApiClient', async () => { - const { createApiClient } = await import('@bytelyst/api-client'); - const getToken = () => 'my-token'; - - createExtractionClient({ baseUrl: 'http://localhost:4005', getToken }); - - expect(createApiClient).toHaveBeenCalledWith({ - baseUrl: 'http://localhost:4005', - getToken, - }); - }); - - it('works without getToken', async () => { - const { createApiClient } = await import('@bytelyst/api-client'); - - createExtractionClient({ baseUrl: 'http://localhost:4005' }); - - expect(createApiClient).toHaveBeenCalledWith({ - baseUrl: 'http://localhost:4005', - getToken: undefined, - }); - }); - }); -}); - -describe('Extraction types', () => { - it('ExtractionEntity shape is correct', () => { - const entity: ExtractionEntity = { - extraction_class: 'person', - extraction_text: 'John Doe', - attributes: { role: 'engineer' }, - start_offset: 0, - end_offset: 8, - }; - expect(entity.extraction_class).toBe('person'); - expect(entity.attributes?.role).toBe('engineer'); - }); - - it('ExtractionExample shape is correct', () => { - const example: ExtractionExample = { - text: 'Meet Bob at 3pm', - extractions: [ - { extraction_class: 'person', extraction_text: 'Bob' }, - { extraction_class: 'time', extraction_text: '3pm' }, - ], - }; - expect(example.extractions).toHaveLength(2); - }); - - it('ExtractionClientConfig with optional getToken', () => { - const config1: ExtractionClientConfig = { baseUrl: 'http://localhost:4005' }; - expect(config1.getToken).toBeUndefined(); - - const config2: ExtractionClientConfig = { - baseUrl: 'http://localhost:4005', - getToken: () => 'tok', - }; - expect(config2.getToken?.()).toBe('tok'); - }); -}); diff --git a/vendor/bytelyst/extraction/src/client.ts b/vendor/bytelyst/extraction/src/client.ts deleted file mode 100644 index 7c105ad..0000000 --- a/vendor/bytelyst/extraction/src/client.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Extraction service client factory. - * Uses @bytelyst/api-client under the hood for consistent auth token injection. - */ - -import { createApiClient } from '@bytelyst/api-client'; - -import type { - ExtractionClientConfig, - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - ExtractionTask, - TranscribeRequest, - TranscribeResponse, -} from './types.js'; - -export interface ExtractionClient { - /** Single document extraction. */ - extract(req: ExtractRequest): Promise; - - /** Batch extraction (multiple inputs, shared config). */ - extractBatch(req: BatchExtractRequest): Promise; - - /** List available extraction tasks. */ - listTasks(productId?: string): Promise; - - /** Get a single task by ID. */ - getTask(id: string, productId?: string): Promise; - - /** Transcribe audio from a URL via the configured STT provider. */ - transcribe(req: TranscribeRequest): Promise; -} - -/** - * Create a typed extraction service client. - * - * @example - * ```ts - * const client = createExtractionClient({ - * baseUrl: "http://localhost:4005", - * getToken: () => localStorage.getItem("access_token"), - * }); - * - * const result = await client.extract({ - * text: "John said we should ship by Friday.", - * taskId: "transcript-extraction", - * }); - * ``` - */ -export function createExtractionClient(config: ExtractionClientConfig): ExtractionClient { - const api = createApiClient({ - baseUrl: config.baseUrl, - getToken: config.getToken, - }); - - return { - async extract(req: ExtractRequest): Promise { - return api.fetch('/api/extract', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - - async extractBatch(req: BatchExtractRequest): Promise { - return api.fetch('/api/extract/batch', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - - async listTasks(productId?: string): Promise { - const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; - return api.fetch(`/api/tasks${qs}`); - }, - - async getTask(id: string, productId?: string): Promise { - const qs = productId ? `?productId=${encodeURIComponent(productId)}` : ''; - return api.fetch(`/api/tasks/${encodeURIComponent(id)}${qs}`); - }, - - async transcribe(req: TranscribeRequest): Promise { - return api.fetch('/api/transcribe', { - method: 'POST', - body: JSON.stringify(req), - }); - }, - }; -} diff --git a/vendor/bytelyst/extraction/src/index.ts b/vendor/bytelyst/extraction/src/index.ts deleted file mode 100644 index e8dbffb..0000000 --- a/vendor/bytelyst/extraction/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { createExtractionClient } from './client.js'; -export type { ExtractionClient } from './client.js'; -export type { - ExtractionEntity, - ExtractionExample, - ExtractionTask, - ExtractRequest, - ExtractResponse, - BatchExtractRequest, - BatchExtractResponse, - TranscribeRequest, - TranscribeResponse, - ExtractionClientConfig, -} from './types.js'; diff --git a/vendor/bytelyst/extraction/src/types.ts b/vendor/bytelyst/extraction/src/types.ts deleted file mode 100644 index a3fbab3..0000000 --- a/vendor/bytelyst/extraction/src/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -// ── Extraction types (shared across consumers) ───────────────── - -export interface ExtractionEntity { - extraction_class: string; - extraction_text: string; - attributes?: Record; - start_offset?: number; - end_offset?: number; -} - -export interface ExtractionExample { - text: string; - extractions: ExtractionEntity[]; -} - -export interface ExtractionTask { - id: string; - name: string; - description?: string; - prompt: string; - classes: string[]; - examples?: ExtractionExample[]; - defaultModelId?: string; - builtIn: boolean; - productId: string; - createdAt?: string; - updatedAt?: string; -} - -// ── Request / Response types ──────────────────────────────────── - -export interface ExtractRequest { - text: string; - taskId?: string; - taskPrompt?: string; - examples?: ExtractionExample[]; - modelId?: string; - options?: { - extractionPasses?: number; - maxWorkers?: number; - maxCharBuffer?: number; - }; - productId?: string; -} - -export interface ExtractResponse { - extractions: ExtractionEntity[]; - metadata: { - modelId: string; - durationMs: number; - tokenCount?: number; - charCount: number; - }; - requestId?: string; -} - -export interface BatchExtractRequest { - inputs: Array<{ - text: string; - taskId?: string; - taskPrompt?: string; - }>; - examples?: ExtractionExample[]; - modelId?: string; - productId?: string; -} - -export interface BatchExtractResponse { - results: ExtractResponse[]; - requestId?: string; -} - -// ── Transcription types ───────────────────────────────────────── - -export interface TranscribeRequest { - /** URL of the audio file (e.g. Azure Blob SAS URL). */ - audioUrl: string; - /** Override the Whisper model (default: whisper-1). */ - model?: string; - /** ISO 639-1 language hint (e.g. 'en', 'es'). Improves accuracy. */ - language?: string; - /** Optional prompt to guide the transcription style. */ - prompt?: string; - /** Response format: 'text' | 'json' | 'verbose_json'. */ - responseFormat?: 'text' | 'json' | 'verbose_json'; - /** Product ID for scoping / rate limiting. */ - productId?: string; -} - -export interface TranscribeResponse { - /** The transcribed text. */ - text: string; - /** Detected or specified language code. */ - language: string | null; - /** Duration of the audio in seconds (when available). */ - durationSeconds: number | null; - /** Whisper model used. */ - model: string; - /** Processing time in milliseconds. */ - durationMs: number; - /** Request ID for tracing. */ - requestId?: string; -} - -// ── Client config ─────────────────────────────────────────────── - -export interface ExtractionClientConfig { - baseUrl: string; - getToken?: () => string | null; -} diff --git a/vendor/bytelyst/extraction/tsconfig.json b/vendor/bytelyst/extraction/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/extraction/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/fastify-auth/package.json b/vendor/bytelyst/fastify-auth/package.json deleted file mode 100644 index 59edb9b..0000000 --- a/vendor/bytelyst/fastify-auth/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@bytelyst/fastify-auth", - "version": "0.1.5", - "description": "JWT auth middleware + request context for Fastify product backends", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run --pool forks", - "clean": "rm -rf dist" - }, - "peerDependencies": { - "fastify": ">=5.0.0", - "jose": ">=5.0.0" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "devDependencies": { - "fastify": "^5.2.1", - "jose": "^6.0.8", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "files": [ - "dist" - ], - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-auth/src/auth.ts b/vendor/bytelyst/fastify-auth/src/auth.ts deleted file mode 100644 index 9828931..0000000 --- a/vendor/bytelyst/fastify-auth/src/auth.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Configurable JWT auth middleware — RS256 JWKS verification with HS256 fallback. - * - * Factory function creates extractAuth() and requireRole() bound to the - * provided config, eliminating the need for each product backend to maintain - * its own copy. - */ -import { jwtVerify, createRemoteJWKSet } from 'jose'; -import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; -import type { AuthPayload, FastifyAuthOptions } from './types.js'; - -export function createAuthMiddleware(opts: FastifyAuthOptions) { - // Lazy-init JWKS client (cached, auto-refreshed by jose) - let jwks: ReturnType | null = null; - let cachedJwksUrl: string | undefined; - - function resolveJwksUrl(): string | undefined { - return typeof opts.jwksUrl === 'function' ? opts.jwksUrl() : opts.jwksUrl; - } - - function resolveJwtSecret(): string { - return typeof opts.jwtSecret === 'function' ? opts.jwtSecret() : opts.jwtSecret; - } - - function getJWKS(): ReturnType | null { - const url = resolveJwksUrl(); - if (!url) return null; - if (jwks && cachedJwksUrl === url) return jwks; - jwks = createRemoteJWKSet(new URL(url)); - cachedJwksUrl = url; - return jwks; - } - - function getHmacSecret(): Uint8Array { - return new TextEncoder().encode(resolveJwtSecret()); - } - - /** - * Extract and verify auth payload from an Authorization header. - * Tries RS256 via JWKS first, falls back to HS256. - */ - async function extractAuth(req: { headers: { authorization?: string } }): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) { - throw new UnauthorizedError(); - } - const token = auth.slice(7); - - // Try RS256 via JWKS first - const remoteJWKS = getJWKS(); - if (remoteJWKS) { - try { - const { payload } = await jwtVerify(token, remoteJWKS); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - // Fall through to HS256 - } - } - - // Fall back to HS256 (existing behavior) - try { - const { payload } = await jwtVerify(token, getHmacSecret()); - const p = payload as unknown as AuthPayload; - if (p.type !== 'access') throw new Error('Not an access token'); - return p; - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } - } - - /** - * Require specific roles. Extracts auth first, then checks role. - */ - async function requireRole( - req: { headers: { authorization?: string } }, - ...roles: string[] - ): Promise { - const payload = await extractAuth(req); - if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw new ForbiddenError('Insufficient permissions'); - } - return payload; - } - - return { extractAuth, requireRole }; -} diff --git a/vendor/bytelyst/fastify-auth/src/index.test.ts b/vendor/bytelyst/fastify-auth/src/index.test.ts deleted file mode 100644 index 23a0f11..0000000 --- a/vendor/bytelyst/fastify-auth/src/index.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { SignJWT } from 'jose'; -import { createAuthMiddleware, createRequestContext } from './index.js'; -import type { JwtPayload } from './types.js'; - -const TEST_SECRET = 'test-jwt-secret-for-fastify-auth-package'; - -interface MockReq { - headers: Record; - jwtPayload?: JwtPayload; -} - -function makeReq(token?: string): MockReq { - return { - headers: { - authorization: token ? `Bearer ${token}` : undefined, - }, - jwtPayload: undefined, - }; -} - -async function signToken(payload: Record, secret = TEST_SECRET) { - return new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('5m') - .sign(new TextEncoder().encode(secret)); -} - -describe('createAuthMiddleware', () => { - const { extractAuth, requireRole } = createAuthMiddleware({ - jwtSecret: TEST_SECRET, - }); - - it('extracts auth payload from valid access token', async () => { - const token = await signToken({ - sub: 'user-1', - email: 'test@test.com', - role: 'admin', - type: 'access', - }); - const payload = await extractAuth(makeReq(token)); - expect(payload.sub).toBe('user-1'); - expect(payload.email).toBe('test@test.com'); - expect(payload.role).toBe('admin'); - }); - - it('throws UnauthorizedError when no Authorization header', async () => { - await expect(extractAuth(makeReq())).rejects.toThrow('Unauthorized'); - }); - - it('throws UnauthorizedError for invalid token', async () => { - await expect(extractAuth(makeReq('bad-token'))).rejects.toThrow('Invalid or expired token'); - }); - - it('throws UnauthorizedError for non-access token type', async () => { - const token = await signToken({ sub: 'u1', type: 'refresh' }); - await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); - }); - - it('throws UnauthorizedError for wrong secret', async () => { - const token = await signToken({ sub: 'u1', type: 'access' }, 'wrong'); - await expect(extractAuth(makeReq(token))).rejects.toThrow('Invalid or expired token'); - }); - - it('requireRole passes when role matches', async () => { - const token = await signToken({ - sub: 'u1', - role: 'admin', - type: 'access', - }); - const payload = await requireRole(makeReq(token), 'admin', 'superadmin'); - expect(payload.sub).toBe('u1'); - }); - - it('requireRole throws ForbiddenError when role does not match', async () => { - const token = await signToken({ - sub: 'u1', - role: 'viewer', - type: 'access', - }); - await expect(requireRole(makeReq(token), 'admin')).rejects.toThrow('Insufficient permissions'); - }); - - it('requireRole passes with no required roles (any authenticated user)', async () => { - const token = await signToken({ - sub: 'u1', - type: 'access', - }); - const payload = await requireRole(makeReq(token)); - expect(payload.sub).toBe('u1'); - }); -}); - -describe('createRequestContext', () => { - const { getRequestProductId, getUserId } = createRequestContext({ - productId: 'testproduct', - }); - - function makeFastifyReq(overrides?: { - jwtPayload?: JwtPayload; - headers?: Record; - }) { - const req = makeReq(); - if (overrides?.jwtPayload !== undefined) req.jwtPayload = overrides.jwtPayload; - if (overrides?.headers) Object.assign(req.headers, overrides.headers); - return req as unknown as import('fastify').FastifyRequest; - } - - it('returns product ID for valid request', () => { - expect(getRequestProductId(makeFastifyReq())).toBe('testproduct'); - }); - - it('returns product ID when JWT productId matches', () => { - expect( - getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'testproduct' } })) - ).toBe('testproduct'); - }); - - it('throws BadRequestError when JWT productId does not match', () => { - expect(() => - getRequestProductId(makeFastifyReq({ jwtPayload: { sub: 'u1', productId: 'wrong' } })) - ).toThrow('Invalid productId'); - }); - - it('throws BadRequestError when X-Product-Id header does not match', () => { - expect(() => - getRequestProductId(makeFastifyReq({ headers: { 'x-product-id': 'wrong' } })) - ).toThrow('Invalid productId'); - }); - - it('getUserId returns sub from JWT payload', () => { - expect(getUserId(makeFastifyReq({ jwtPayload: { sub: 'user-42' } }))).toBe('user-42'); - }); - - it('getUserId throws when no JWT payload', () => { - expect(() => getUserId(makeFastifyReq())).toThrow('Missing userId'); - }); - - it('getUserId throws when JWT has no sub', () => { - expect(() => getUserId(makeFastifyReq({ jwtPayload: {} as JwtPayload }))).toThrow( - 'Missing userId' - ); - }); -}); diff --git a/vendor/bytelyst/fastify-auth/src/index.ts b/vendor/bytelyst/fastify-auth/src/index.ts deleted file mode 100644 index 903ec4c..0000000 --- a/vendor/bytelyst/fastify-auth/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createAuthMiddleware } from './auth.js'; -export { createRequestContext } from './request-context.js'; -export type { - AuthPayload, - JwtPayload, - FastifyAuthOptions, - RequestContextOptions, -} from './types.js'; diff --git a/vendor/bytelyst/fastify-auth/src/request-context.ts b/vendor/bytelyst/fastify-auth/src/request-context.ts deleted file mode 100644 index 27eda5e..0000000 --- a/vendor/bytelyst/fastify-auth/src/request-context.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Configurable request context helpers for Fastify product backends. - * - * Factory function creates getRequestProductId() and getUserId() bound to - * the provided product ID, eliminating hardcoded product IDs in each repo. - */ -import type { FastifyRequest } from 'fastify'; -import { BadRequestError } from '@bytelyst/errors'; -import type { RequestContextOptions } from './types.js'; - -export function createRequestContext(opts: RequestContextOptions) { - const { productId } = opts; - - /** - * Extract productId from request. Validates against this backend's product ID. - * Falls back to the configured productId since this is a product-specific backend. - */ - function getRequestProductId(req: FastifyRequest): string { - // 1. From JWT - const jwtPid = req.jwtPayload?.productId; - if (jwtPid && jwtPid !== productId) { - throw new BadRequestError(`Invalid productId: expected ${productId}, got ${jwtPid}`); - } - - // 2. From header - const header = req.headers['x-product-id']; - if (typeof header === 'string' && header.length > 0 && header !== productId) { - throw new BadRequestError(`Invalid productId: expected ${productId}, got ${header}`); - } - - return productId; - } - - /** - * Extract userId from the JWT payload on the request. - * Throws BadRequestError if no authenticated user is found. - */ - function getUserId(req: FastifyRequest): string { - const sub = req.jwtPayload?.sub; - if (!sub) { - throw new BadRequestError('Missing userId — request must be authenticated'); - } - return sub; - } - - return { getRequestProductId, getUserId }; -} diff --git a/vendor/bytelyst/fastify-auth/src/types.ts b/vendor/bytelyst/fastify-auth/src/types.ts deleted file mode 100644 index 32dd973..0000000 --- a/vendor/bytelyst/fastify-auth/src/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * JWT payload shape expected from platform-service tokens. - * Re-exported from @bytelyst/auth for convenience. - */ -export interface AuthPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; - iat?: number; - exp?: number; - iss?: string; -} - -/** JWT payload shape attached to req by the onRequest hook. */ -export interface JwtPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; -} - -/** Options for creating the auth middleware. */ -export interface FastifyAuthOptions { - /** HS256 symmetric secret for JWT verification. May be a getter for dynamic config. */ - jwtSecret: string | (() => string); - /** Optional RS256 JWKS endpoint URL (tried first, falls back to HS256). May be a getter. */ - jwksUrl?: string | (() => string | undefined); -} - -/** Options for creating the request context helpers. */ -export interface RequestContextOptions { - /** The product ID this backend serves (e.g. 'peakpulse', 'nomgap'). */ - productId: string; -} - -// Augment Fastify request to include parsed JWT payload -declare module 'fastify' { - interface FastifyRequest { - jwtPayload?: JwtPayload; - } -} diff --git a/vendor/bytelyst/fastify-auth/tsconfig.json b/vendor/bytelyst/fastify-auth/tsconfig.json deleted file mode 100644 index 01c4d9a..0000000 --- a/vendor/bytelyst/fastify-auth/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "declaration": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/fastify-core/package.json b/vendor/bytelyst/fastify-core/package.json deleted file mode 100644 index 20a6dfc..0000000 --- a/vendor/bytelyst/fastify-core/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@bytelyst/fastify-core", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "@fastify/cors": ">=10.0.0", - "@fastify/swagger": ">=9.0.0", - "fastify": ">=5.0.0", - "fastify-metrics": ">=10.0.0" - }, - "peerDependenciesMeta": { - "@fastify/swagger": { - "optional": true - }, - "fastify-metrics": { - "optional": true - } - }, - "devDependencies": { - "@fastify/swagger": "^9.7.0", - "@fastify/swagger-ui": "^5.2.5", - "fastify-metrics": "^10.6.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts b/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts deleted file mode 100644 index 77e6233..0000000 --- a/vendor/bytelyst/fastify-core/src/__tests__/fastify-core.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createServiceApp, registerOptionalJwtContext, startService } from '../index.js'; - -type JwtRequest = { jwtPayload?: unknown }; - -describe('createServiceApp', () => { - it('returns a Fastify instance', async () => { - const app = await createServiceApp({ - name: 'test-service', - version: '0.0.1', - logger: false, - }); - expect(app).toBeDefined(); - expect(typeof app.listen).toBe('function'); - expect(typeof app.get).toBe('function'); - await app.close(); - }); - - it('has a /health endpoint returning correct shape', async () => { - const app = await createServiceApp({ - name: 'my-service', - version: '1.2.3', - description: 'A test service', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.statusCode).toBe(200); - - const body = JSON.parse(res.payload); - expect(body.status).toBe('ok'); - expect(body.service).toBe('my-service'); - expect(body.version).toBe('1.2.3'); - expect(body.description).toBe('A test service'); - expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('propagates x-request-id header', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - const customId = 'req-12345'; - const res = await app.inject({ - method: 'GET', - url: '/health', - headers: { 'x-request-id': customId }, - }); - - expect(res.headers['x-request-id']).toBe(customId); - const body = JSON.parse(res.payload); - expect(body.requestId).toBe(customId); - - await app.close(); - }); - - it('generates x-request-id when not provided', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - expect(res.headers['x-request-id']).toBeTruthy(); - // Should be UUID format - expect(res.headers['x-request-id']).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ - ); - const body = JSON.parse(res.payload); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles ServiceError with correct status code', async () => { - const { NotFoundError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/fail', async () => { - throw new NotFoundError('User not found'); - }); - - const res = await app.inject({ method: 'GET', url: '/fail' }); - expect(res.statusCode).toBe(404); - const body = JSON.parse(res.payload); - expect(body.error).toBe('User not found'); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles ServiceError with details', async () => { - const { BadRequestError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/bad', async () => { - throw new BadRequestError('Validation failed', { field: 'email' }); - }); - - const res = await app.inject({ method: 'GET', url: '/bad' }); - expect(res.statusCode).toBe(400); - const body = JSON.parse(res.payload); - expect(body.error).toBe('Validation failed'); - expect(body.details).toEqual({ field: 'email' }); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('handles unknown errors as 500', async () => { - const app = await createServiceApp({ - name: 'test', - version: '0.1.0', - logger: false, - }); - - app.get('/crash', async () => { - throw new Error('unexpected'); - }); - - const res = await app.inject({ method: 'GET', url: '/crash' }); - expect(res.statusCode).toBe(500); - const body = JSON.parse(res.payload); - expect(body.error).toBe('Internal server error'); - expect(body.requestId).toBe(res.headers['x-request-id']); - - await app.close(); - }); - - it('omits description from health when not provided', async () => { - const app = await createServiceApp({ - name: 'minimal', - version: '0.1.0', - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/health' }); - const body = JSON.parse(res.payload); - expect(body.description).toBeUndefined(); - - await app.close(); - }); - - it('supports readiness endpoint and returns not_ready until enabled', async () => { - const app = await createServiceApp({ - name: 'ready-test', - version: '1.0.0', - readiness: true, - logger: false, - }); - - const before = await app.inject({ method: 'GET', url: '/ready' }); - expect(before.statusCode).toBe(503); - expect(JSON.parse(before.payload)).toMatchObject({ - status: 'not_ready', - service: 'ready-test', - version: '1.0.0', - requestId: before.headers['x-request-id'], - }); - - app.setReadyState(true); - - const after = await app.inject({ method: 'GET', url: '/ready' }); - expect(after.statusCode).toBe(200); - expect(JSON.parse(after.payload)).toMatchObject({ - status: 'ready', - service: 'ready-test', - version: '1.0.0', - requestId: after.headers['x-request-id'], - }); - - await app.close(); - }); - - it('supports custom readiness path and initial readiness state', async () => { - const app = await createServiceApp({ - name: 'custom-ready', - version: '1.0.0', - readiness: { path: '/status/ready', initialReady: true }, - logger: false, - }); - - const res = await app.inject({ method: 'GET', url: '/status/ready' }); - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toMatchObject({ - status: 'ready', - service: 'custom-ready', - }); - - await app.close(); - }); - - it('can omit requestId from error responses when configured', async () => { - const { BadRequestError } = await import('@bytelyst/errors'); - const app = await createServiceApp({ - name: 'error-config', - version: '1.0.0', - logger: false, - errorResponses: { includeRequestId: false }, - }); - - app.get('/bad', async () => { - throw new BadRequestError('No request id please'); - }); - - const res = await app.inject({ method: 'GET', url: '/bad' }); - expect(res.statusCode).toBe(400); - const body = JSON.parse(res.payload); - expect(body.error).toBe('No request id please'); - expect(body.requestId).toBeUndefined(); - - await app.close(); - }); -}); - -describe('registerOptionalJwtContext', () => { - it('attaches jwtPayload when bearer token verification succeeds', async () => { - const app = await createServiceApp({ - name: 'jwt-context', - version: '1.0.0', - logger: false, - }); - - await registerOptionalJwtContext(app, { - verifyToken: async token => ({ sub: `user:${token}`, role: 'admin' }), - }); - - app.get('/secure', async req => ({ jwtPayload: (req as typeof req & JwtRequest).jwtPayload })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer abc123' }, - }); - - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toEqual({ - jwtPayload: { sub: 'user:abc123', role: 'admin' }, - }); - - await app.close(); - }); - - it('swallows verification errors by default for optional auth', async () => { - const app = await createServiceApp({ - name: 'jwt-optional', - version: '1.0.0', - logger: false, - }); - - await registerOptionalJwtContext(app, { - verifyToken: async () => { - throw new Error('invalid token'); - }, - }); - - app.get('/secure', async req => ({ - jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, - })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer broken' }, - }); - - expect(res.statusCode).toBe(200); - expect(JSON.parse(res.payload)).toEqual({ jwtPayload: null }); - - await app.close(); - }); - - it('invokes onError callback when token verification fails', async () => { - const app = await createServiceApp({ - name: 'jwt-onerror', - version: '1.0.0', - logger: false, - }); - - const onError = vi.fn(); - - await registerOptionalJwtContext(app, { - verifyToken: async () => { - throw new Error('bad token'); - }, - onError, - }); - - app.get('/secure', async req => ({ - jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null, - })); - - const res = await app.inject({ - method: 'GET', - url: '/secure', - headers: { authorization: 'Bearer broken' }, - }); - - expect(res.statusCode).toBe(200); - expect(onError).toHaveBeenCalledOnce(); - - await app.close(); - }); -}); - -describe('startService', () => { - it('sets readiness state after successful listen', async () => { - const app = await createServiceApp({ - name: 'start-ready', - version: '1.0.0', - readiness: true, - logger: false, - }); - - const listenMock = vi.fn(async () => 'http://127.0.0.1:9999'); - const closeMock = vi.fn(async () => undefined); - const infoMock = vi.fn(); - const errorMock = vi.fn(); - - app.listen = listenMock as typeof app.listen; - app.close = closeMock as unknown as typeof app.close; - app.log.info = infoMock as typeof app.log.info; - app.log.error = errorMock as typeof app.log.error; - - expect(app.isReadyState()).toBe(false); - - await startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }); - - expect(listenMock).toHaveBeenCalledWith({ port: 9999, host: '0.0.0.0' }); - expect(app.isReadyState()).toBe(true); - expect(errorMock).not.toHaveBeenCalled(); - - await app.close(); - }); - - it('throws startup error instead of exiting when exitOnFatal is false', async () => { - const app = await createServiceApp({ - name: 'start-fail', - version: '1.0.0', - logger: false, - }); - - const startupError = new Error('listen failed'); - const listenMock = vi.fn(async () => { - throw startupError; - }); - const errorMock = vi.fn(); - - app.listen = listenMock as typeof app.listen; - app.log.error = errorMock as typeof app.log.error; - - await expect( - startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false }) - ).rejects.toThrow('listen failed'); - - expect(errorMock).toHaveBeenCalledWith(startupError); - - await app.close(); - }); -}); diff --git a/vendor/bytelyst/fastify-core/src/auth.ts b/vendor/bytelyst/fastify-core/src/auth.ts deleted file mode 100644 index a443989..0000000 --- a/vendor/bytelyst/fastify-core/src/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { FastifyRequest } from 'fastify'; -import type { FastifyApp } from './types.js'; - -interface JwtCarrier { - jwtPayload?: unknown; -} - -export interface OptionalJwtContextOptions { - onError?: (error: unknown, request: FastifyRequest) => Promise | void; - verifyToken: (token: string, request: FastifyRequest) => Promise | TPayload; -} - -export async function registerOptionalJwtContext( - app: FastifyApp, - options: OptionalJwtContextOptions -): Promise { - const { verifyToken, onError } = options; - - app.addHook('onRequest', async req => { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) return; - - try { - const payload = await verifyToken(auth.slice(7), req); - (req as FastifyRequest & JwtCarrier).jwtPayload = payload; - } catch (error) { - if (onError) { - await onError(error, req); - } - } - }); -} diff --git a/vendor/bytelyst/fastify-core/src/create-app.ts b/vendor/bytelyst/fastify-core/src/create-app.ts deleted file mode 100644 index 4c4ad6d..0000000 --- a/vendor/bytelyst/fastify-core/src/create-app.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Factory for creating a Fastify service app with standard middleware. - * - * Includes: CORS, x-request-id propagation, health endpoint, ServiceError handler. - */ - -import { randomUUID } from 'node:crypto'; -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import { ServiceError } from '@bytelyst/errors'; -import type { ServiceAppOptions, FastifyApp } from './types.js'; - -/** - * Create a Fastify app preconfigured with common middleware. - * - * @example - * ```ts - * const app = await createServiceApp({ - * name: "platform-service", - * version: "0.1.0", - * description: "Auth, audit, flags, notifications", - * corsOrigin: process.env.CORS_ORIGIN, - * }); - * await app.register(authRoutes, { prefix: "/api" }); - * await startService(app, { port: 4003 }); - * ``` - */ -export async function createServiceApp(options: ServiceAppOptions): Promise { - const { - name, - version, - description, - corsOrigin, - logger = true, - logLevel, - swagger, - metrics, - readiness, - errorResponses, - optionalPluginFailure = 'warn', - } = options; - - const app = Fastify({ - logger: logger ? (logLevel ? { level: logLevel } : true) : false, - }) as unknown as FastifyApp; - - let readyState = typeof readiness === 'object' ? (readiness.initialReady ?? false) : false; - const readinessPath = typeof readiness === 'object' ? (readiness.path ?? '/ready') : '/ready'; - const includeRequestIdInErrors = errorResponses?.includeRequestId ?? true; - - app.setReadyState = (ready: boolean) => { - readyState = ready; - }; - - app.isReadyState = () => readyState; - - // CORS — deny all origins when CORS_ORIGIN is not explicitly set - const origin = corsOrigin ? corsOrigin.split(',').map(o => o.trim()) : false; - await app.register(cors, { origin }); - - // OpenAPI spec (optional — consumer must have @fastify/swagger installed) - if (swagger) { - try { - const swaggerPlugin = (await import('@fastify/swagger')).default; - await app.register(swaggerPlugin, { - openapi: { - info: { - title: swagger.title, - version, - ...(swagger.description && { description: swagger.description }), - }, - ...(swagger.port && { servers: [{ url: `http://localhost:${swagger.port}` }] }), - }, - }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin @fastify/swagger not available; skipping OpenAPI registration' - ); - } - - // Swagger UI — serves interactive API docs at /documentation - try { - const swaggerUiPlugin = (await import('@fastify/swagger-ui')).default; - await app.register(swaggerUiPlugin, { - routePrefix: '/documentation', - uiConfig: { docExpansion: 'list', deepLinking: true }, - }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin @fastify/swagger-ui not available; skipping Swagger UI registration' - ); - } - } - - // Prometheus metrics (optional — consumer must have fastify-metrics installed) - if (metrics) { - try { - const metricsMod = await import('fastify-metrics'); - const plugin = metricsMod.default as unknown as Parameters[0]; - await app.register(plugin, { endpoint: '/metrics' }); - } catch (error) { - if (optionalPluginFailure === 'throw') { - throw error; - } - app.log.warn( - { err: error }, - 'Optional plugin fastify-metrics not available; skipping metrics registration' - ); - } - } - - // x-request-id propagation - app.addHook('onRequest', async (req, reply) => { - const requestId = (req.headers['x-request-id'] as string) || randomUUID(); - req.headers['x-request-id'] = requestId; - reply.header('x-request-id', requestId); - req.log = req.log.child({ requestId }); - }); - - // Health check - app.get('/health', async req => ({ - status: 'ok', - service: name, - version, - ...(description && { description }), - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], - })); - - if (readiness) { - app.get(readinessPath, async (req, reply) => { - if (!app.isReadyState()) { - reply.code(503); - } - - return { - status: app.isReadyState() ? 'ready' : 'not_ready', - service: name, - version, - ...(description && { description }), - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'], - }; - }); - } - - // ServiceError-aware error handler - app.setErrorHandler((error, req, reply) => { - if (error instanceof ServiceError) { - const body: Record = { error: error.message }; - if (error.details) body.details = error.details; - if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; - reply.code(error.statusCode).send(body); - return; - } - app.log.error(error); - const body: Record = { error: 'Internal server error' }; - if (includeRequestIdInErrors) body.requestId = req.headers['x-request-id']; - reply.code(500).send(body); - }); - - return app; -} diff --git a/vendor/bytelyst/fastify-core/src/index.ts b/vendor/bytelyst/fastify-core/src/index.ts deleted file mode 100644 index e198e58..0000000 --- a/vendor/bytelyst/fastify-core/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { createServiceApp } from './create-app.js'; -export { registerOptionalJwtContext } from './auth.js'; -export { startService } from './start.js'; -export type { ServiceAppOptions, StartOptions, FastifyApp } from './types.js'; diff --git a/vendor/bytelyst/fastify-core/src/start.ts b/vendor/bytelyst/fastify-core/src/start.ts deleted file mode 100644 index a4054d7..0000000 --- a/vendor/bytelyst/fastify-core/src/start.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Service startup helper — starts the app and logs the address. - */ - -import type { FastifyApp, StartOptions } from './types.js'; - -const registeredSignalHandlers = new Map>(); - -export async function startService(app: FastifyApp, options: StartOptions): Promise { - const { port, host = '0.0.0.0', exitOnFatal = true, registerSignalHandlers = true } = options; - - // Graceful shutdown on SIGTERM/SIGINT (Docker, K8s, Ctrl-C) - if (registerSignalHandlers) { - for (const signal of ['SIGTERM', 'SIGINT'] as const) { - const appsForSignal = registeredSignalHandlers.get(signal) ?? new WeakSet(); - - if (!appsForSignal.has(app)) { - appsForSignal.add(app); - registeredSignalHandlers.set(signal, appsForSignal); - - process.once(signal, async () => { - app.log.info(`Received ${signal}, shutting down gracefully…`); - await app.close(); - if (exitOnFatal) { - process.exit(0); - } - }); - } - } - } - - try { - await app.listen({ port, host }); - if (typeof app.setReadyState === 'function') { - app.setReadyState(true); - } - app.log.info(`Service listening on ${host}:${port}`); - } catch (err) { - app.log.error(err); - if (exitOnFatal) { - process.exit(1); - } - throw err; - } -} diff --git a/vendor/bytelyst/fastify-core/src/types.ts b/vendor/bytelyst/fastify-core/src/types.ts deleted file mode 100644 index 0b2d5cc..0000000 --- a/vendor/bytelyst/fastify-core/src/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FastifyInstance } from 'fastify'; - -export interface SwaggerOptions { - title: string; - description?: string; - port?: number; -} - -export interface ReadinessOptions { - path?: string; - initialReady?: boolean; -} - -export interface ErrorResponseOptions { - includeRequestId?: boolean; -} - -export interface ServiceAppOptions { - name: string; - version: string; - description?: string; - corsOrigin?: string; - logger?: boolean; - logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - swagger?: SwaggerOptions; - metrics?: boolean; - readiness?: boolean | ReadinessOptions; - errorResponses?: ErrorResponseOptions; - optionalPluginFailure?: 'warn' | 'throw'; -} - -export interface StartOptions { - port: number; - host?: string; - exitOnFatal?: boolean; - registerSignalHandlers?: boolean; -} - -export type FastifyApp = FastifyInstance & { - setReadyState: (ready: boolean) => void; - isReadyState: () => boolean; -}; diff --git a/vendor/bytelyst/fastify-core/tsconfig.json b/vendor/bytelyst/fastify-core/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/fastify-core/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/fastify-sse/package.json b/vendor/bytelyst/fastify-sse/package.json deleted file mode 100644 index 279e2e8..0000000 --- a/vendor/bytelyst/fastify-sse/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/fastify-sse", - "version": "0.3.5", - "description": "Fastify plugin for Server-Sent Events (SSE) — real-time push for ByteLyst product backends", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "fastify": "^5.0.0" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/fastify-sse/src/hub.test.ts b/vendor/bytelyst/fastify-sse/src/hub.test.ts deleted file mode 100644 index fbfccb7..0000000 --- a/vendor/bytelyst/fastify-sse/src/hub.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { SSEHub } from './hub.js'; -import { EventEmitter } from 'node:events'; -import type { ServerResponse } from 'node:http'; - -function mockResponse(): ServerResponse { - const emitter = new EventEmitter(); - const chunks: string[] = []; - const res = Object.assign(emitter, { - writeHead: vi.fn(), - write: vi.fn((chunk: string) => { - chunks.push(chunk); - return true; - }), - end: vi.fn(), - _chunks: chunks, - }); - return res as unknown as ServerResponse; -} - -describe('SSEHub', () => { - let hub: SSEHub; - - beforeEach(() => { - hub = new SSEHub(); - }); - - describe('addClient', () => { - it('returns a client ID and sets SSE headers', () => { - const res = mockResponse(); - const id = hub.addClient(res); - expect(id).toMatch(/^sse_/); - expect(res.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - 'Content-Type': 'text/event-stream', - }) - ); - expect(hub.clientCount).toBe(1); - }); - - it('sends initial connected event', () => { - const res = mockResponse(); - hub.addClient(res); - expect(res.write).toHaveBeenCalledWith(expect.stringContaining('event: connected')); - }); - - it('removes client on close', () => { - const res = mockResponse(); - hub.addClient(res); - expect(hub.clientCount).toBe(1); - res.emit('close'); - expect(hub.clientCount).toBe(0); - }); - }); - - describe('broadcast', () => { - it('sends to all connected clients', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - - const sent = hub.broadcast({ event: 'test', data: '{"hello":true}' }); - expect(sent).toBe(2); - // Each res gets: initial connected write + broadcast write = 2 calls - expect(res1.write).toHaveBeenCalledTimes(2); - expect(res2.write).toHaveBeenCalledTimes(2); - }); - - it('returns 0 when no clients', () => { - const sent = hub.broadcast({ data: 'test' }); - expect(sent).toBe(0); - }); - - it('removes clients that throw on write', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - - // Make res1 throw on next write - (res1.write as ReturnType).mockImplementationOnce(() => { - throw new Error('broken'); - }); - - hub.broadcast({ data: 'test' }); - // res1 should be removed after the error - expect(hub.clientCount).toBe(1); - }); - }); - - describe('sendToUser', () => { - it('sends only to matching userId', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1, 'user-a'); - hub.addClient(res2, 'user-b'); - - const sent = hub.sendToUser('user-a', { data: 'targeted' }); - expect(sent).toBe(1); - // res1: connected + targeted = 2 writes - expect(res1.write).toHaveBeenCalledTimes(2); - // res2: only connected = 1 write - expect(res2.write).toHaveBeenCalledTimes(1); - }); - - it('returns 0 when no matching users', () => { - const res = mockResponse(); - hub.addClient(res, 'user-a'); - const sent = hub.sendToUser('user-z', { data: 'nothing' }); - expect(sent).toBe(0); - }); - }); - - describe('heartbeat', () => { - it('sends comment to all clients', () => { - const res = mockResponse(); - hub.addClient(res); - hub.heartbeat(); - expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n'); - }); - }); - - describe('disconnectAll', () => { - it('ends all client responses and clears', () => { - const res1 = mockResponse(); - const res2 = mockResponse(); - hub.addClient(res1); - hub.addClient(res2); - expect(hub.clientCount).toBe(2); - - hub.disconnectAll(); - expect(hub.clientCount).toBe(0); - expect(res1.end).toHaveBeenCalled(); - expect(res2.end).toHaveBeenCalled(); - }); - }); - - describe('formatSSE', () => { - it('formats with event, id, and data', () => { - const res = mockResponse(); - hub.addClient(res); - hub.broadcast({ event: 'task.created', data: '{}', id: 'evt_1' }); - - const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; - expect(lastWrite).toContain('id: evt_1'); - expect(lastWrite).toContain('event: task.created'); - expect(lastWrite).toContain('data: {}'); - expect(lastWrite).toMatch(/\n\n$/); - }); - - it('formats with retry field', () => { - const res = mockResponse(); - hub.addClient(res); - hub.broadcast({ data: 'test', retry: 5000 }); - - const lastWrite = (res.write as ReturnType).mock.calls.at(-1)?.[0] as string; - expect(lastWrite).toContain('retry: 5000'); - }); - }); -}); diff --git a/vendor/bytelyst/fastify-sse/src/hub.ts b/vendor/bytelyst/fastify-sse/src/hub.ts deleted file mode 100644 index 7436946..0000000 --- a/vendor/bytelyst/fastify-sse/src/hub.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * SSE Hub — manages connected clients and broadcasts events. - * Product backends create an SSEHub instance and push events to it; - * the hub fans out to all connected SSE clients. - */ - -import type { ServerResponse } from 'node:http'; - -export interface SSEMessage { - event?: string; - data: string; - id?: string; - retry?: number; -} - -interface ConnectedClient { - id: string; - userId?: string; - res: ServerResponse; - connectedAt: string; -} - -export class SSEHub { - private clients = new Map(); - private clientCounter = 0; - - /** - * Add a new SSE client connection. - * Sets up the SSE headers and returns a client ID. - */ - addClient(res: ServerResponse, userId?: string): string { - const id = `sse_${++this.clientCounter}_${Date.now()}`; - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - - // Send initial connection event - res.write(`event: connected\ndata: ${JSON.stringify({ clientId: id })}\n\n`); - - const client: ConnectedClient = { - id, - userId, - res, - connectedAt: new Date().toISOString(), - }; - - this.clients.set(id, client); - - // Clean up on close - res.on('close', () => { - this.clients.delete(id); - }); - - return id; - } - - /** - * Broadcast an SSE message to all connected clients. - */ - broadcast(message: SSEMessage): number { - let sent = 0; - const formatted = formatSSE(message); - - for (const [id, client] of this.clients) { - try { - client.res.write(formatted); - sent++; - } catch { - this.clients.delete(id); - } - } - - return sent; - } - - /** - * Send an SSE message to a specific user's connections. - */ - sendToUser(userId: string, message: SSEMessage): number { - let sent = 0; - const formatted = formatSSE(message); - - for (const [id, client] of this.clients) { - if (client.userId === userId) { - try { - client.res.write(formatted); - sent++; - } catch { - this.clients.delete(id); - } - } - } - - return sent; - } - - /** - * Send a heartbeat (comment) to all clients to keep connections alive. - */ - heartbeat(): void { - for (const [id, client] of this.clients) { - try { - client.res.write(': heartbeat\n\n'); - } catch { - this.clients.delete(id); - } - } - } - - /** - * Get count of connected clients. - */ - get clientCount(): number { - return this.clients.size; - } - - /** - * Disconnect all clients. - */ - disconnectAll(): void { - for (const [, client] of this.clients) { - try { - client.res.end(); - } catch { - /* already closed */ - } - } - this.clients.clear(); - } -} - -function formatSSE(message: SSEMessage): string { - let output = ''; - if (message.id) output += `id: ${message.id}\n`; - if (message.event) output += `event: ${message.event}\n`; - if (message.retry) output += `retry: ${message.retry}\n`; - output += `data: ${message.data}\n\n`; - return output; -} diff --git a/vendor/bytelyst/fastify-sse/src/index.ts b/vendor/bytelyst/fastify-sse/src/index.ts deleted file mode 100644 index a5f3728..0000000 --- a/vendor/bytelyst/fastify-sse/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SSEHub } from './hub.js'; -export { ssePlugin } from './plugin.js'; -export type { SSEPluginOptions, SSEClient } from './plugin.js'; -export { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; diff --git a/vendor/bytelyst/fastify-sse/src/per-request.test.ts b/vendor/bytelyst/fastify-sse/src/per-request.test.ts deleted file mode 100644 index ca21f88..0000000 --- a/vendor/bytelyst/fastify-sse/src/per-request.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { startSSE, sendSSEData, sendSSEEvent, endSSE } from './per-request.js'; -import type { FastifyReply } from 'fastify'; - -function mockReply(): FastifyReply { - const chunks: string[] = []; - return { - raw: { - writeHead: vi.fn(), - write: vi.fn((chunk: string) => { - chunks.push(chunk); - return true; - }), - end: vi.fn(), - _chunks: chunks, - }, - hijack: vi.fn(), - } as unknown as FastifyReply; -} - -describe('per-request SSE helpers', () => { - describe('startSSE', () => { - it('sets correct SSE headers', () => { - const reply = mockReply(); - startSSE(reply); - expect(reply.raw.writeHead).toHaveBeenCalledWith(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - }); - - it('hijacks the reply', () => { - const reply = mockReply(); - startSSE(reply); - expect(reply.hijack).toHaveBeenCalled(); - }); - }); - - describe('sendSSEData', () => { - it('formats object data as JSON', () => { - const reply = mockReply(); - sendSSEData(reply, { hello: true }); - expect(reply.raw.write).toHaveBeenCalledWith('data: {"hello":true}\n\n'); - }); - - it('formats string data without double-encoding', () => { - const reply = mockReply(); - sendSSEData(reply, 'plain text'); - expect(reply.raw.write).toHaveBeenCalledWith('data: plain text\n\n'); - }); - - it('formats number data as JSON', () => { - const reply = mockReply(); - sendSSEData(reply, 42); - expect(reply.raw.write).toHaveBeenCalledWith('data: 42\n\n'); - }); - }); - - describe('sendSSEEvent', () => { - it('formats named event with object data', () => { - const reply = mockReply(); - sendSSEEvent(reply, 'token', { text: 'hi' }); - expect(reply.raw.write).toHaveBeenCalledWith('event: token\ndata: {"text":"hi"}\n\n'); - }); - - it('formats named event with string data', () => { - const reply = mockReply(); - sendSSEEvent(reply, 'status', 'done'); - expect(reply.raw.write).toHaveBeenCalledWith('event: status\ndata: done\n\n'); - }); - }); - - describe('endSSE', () => { - it('sends the [DONE] sentinel', () => { - const reply = mockReply(); - endSSE(reply); - expect(reply.raw.write).toHaveBeenCalledWith('data: [DONE]\n\n'); - }); - - it('ends the stream', () => { - const reply = mockReply(); - endSSE(reply); - expect(reply.raw.end).toHaveBeenCalled(); - }); - }); -}); diff --git a/vendor/bytelyst/fastify-sse/src/per-request.ts b/vendor/bytelyst/fastify-sse/src/per-request.ts deleted file mode 100644 index d1c8dc7..0000000 --- a/vendor/bytelyst/fastify-sse/src/per-request.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Per-request SSE helpers — single-request streaming pattern. - * Used by chat streaming and model comparison endpoints where - * one route handler streams SSE to one client. - */ - -import type { FastifyReply } from 'fastify'; - -export function startSSE(reply: FastifyReply): void { - reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); - reply.hijack(); -} - -export function sendSSEEvent(reply: FastifyReply, event: string, data: unknown): void { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - reply.raw.write(`event: ${event}\ndata: ${payload}\n\n`); -} - -export function sendSSEData(reply: FastifyReply, data: unknown): void { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - reply.raw.write(`data: ${payload}\n\n`); -} - -export function endSSE(reply: FastifyReply): void { - reply.raw.write('data: [DONE]\n\n'); - reply.raw.end(); -} diff --git a/vendor/bytelyst/fastify-sse/src/plugin.ts b/vendor/bytelyst/fastify-sse/src/plugin.ts deleted file mode 100644 index 1c12d64..0000000 --- a/vendor/bytelyst/fastify-sse/src/plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Fastify plugin that registers an SSE endpoint. - * Product backends configure the path and optional auth check. - */ - -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { SSEHub } from './hub.js'; - -export interface SSEClient { - id: string; - userId?: string; -} - -export interface SSEPluginOptions { - /** Route path for the SSE endpoint (default: /events/stream) */ - path?: string; - /** Extract userId from request (optional, enables per-user targeting) */ - getUserId?: (req: FastifyRequest) => string | undefined; - /** Heartbeat interval in ms (default: 30000, set 0 to disable) */ - heartbeatIntervalMs?: number; - /** The SSE hub instance to use (creates one if not provided) */ - hub?: SSEHub; -} - -export async function ssePlugin( - app: FastifyInstance, - options: SSEPluginOptions = {} -): Promise { - const path = options.path ?? '/events/stream'; - const heartbeatMs = options.heartbeatIntervalMs ?? 30_000; - const hub = options.hub ?? new SSEHub(); - - // Decorate app with the hub so routes can push events - if (!app.hasDecorator('sseHub')) { - app.decorate('sseHub', hub); - } - - // Register SSE endpoint - app.get(path, async (req: FastifyRequest, reply: FastifyReply) => { - const userId = options.getUserId?.(req); - - // Hijack the raw response for SSE streaming - const raw = reply.raw; - hub.addClient(raw, userId); - - // Prevent Fastify from sending its own response - reply.hijack(); - }); - - // Heartbeat timer - let heartbeatTimer: ReturnType | undefined; - if (heartbeatMs > 0) { - heartbeatTimer = setInterval(() => hub.heartbeat(), heartbeatMs); - } - - // Cleanup on close - app.addHook('onClose', async () => { - if (heartbeatTimer) clearInterval(heartbeatTimer); - hub.disconnectAll(); - }); -} diff --git a/vendor/bytelyst/fastify-sse/tsconfig.json b/vendor/bytelyst/fastify-sse/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/fastify-sse/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/feature-flag-client/package.json b/vendor/bytelyst/feature-flag-client/package.json deleted file mode 100644 index c9b7097..0000000 --- a/vendor/bytelyst/feature-flag-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/feature-flag-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe feature flag client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/feature-flag-client/src/client.test.ts b/vendor/bytelyst/feature-flag-client/src/client.test.ts deleted file mode 100644 index 8a1788e..0000000 --- a/vendor/bytelyst/feature-flag-client/src/client.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createFeatureFlagClient } from './client.js'; - -describe('createFeatureFlagClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - platform: 'web', - }; - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it('should create a client with isEnabled returning false before init', () => { - const client = createFeatureFlagClient(baseConfig); - expect(client.isEnabled('any_flag')).toBe(false); - }); - - it('should fetch flags on init', async () => { - const mockFlags = { premium: true, beta_feature: false }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: mockFlags }), - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - - expect(client.isEnabled('premium')).toBe(true); - expect(client.isEnabled('beta_feature')).toBe(false); - expect(client.isEnabled('nonexistent')).toBe(false); - client.stop(); - }); - - it('should return all flags via getAllFlags', async () => { - const mockFlags = { a: true, b: false }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: mockFlags }), - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - - expect(client.getAllFlags()).toEqual({ a: true, b: false }); - client.stop(); - }); - - it('should send correct headers', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: {} }), - }); - vi.stubGlobal('fetch', fetchMock); - - const client = createFeatureFlagClient(baseConfig); - await client.init({ userId: 'user-123' }); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/flags/poll?'), - expect.objectContaining({ - headers: expect.objectContaining({ 'x-product-id': 'testapp' }), - }) - ); - - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('platform=web'); - expect(url).toContain('userId=user-123'); - client.stop(); - }); - - it('should keep existing flags on network error', async () => { - let callCount = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 1) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ flags: { initial: true } }), - }); - } - return Promise.reject(new Error('network')); - }) - ); - - const client = createFeatureFlagClient(baseConfig); - await client.init(); - expect(client.isEnabled('initial')).toBe(true); - - await client.refresh(); - expect(client.isEnabled('initial')).toBe(true); - client.stop(); - }); - - it('should persist to storage when provided', async () => { - const store: Record = {}; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: { cached: true } }), - }) - ); - - const client = createFeatureFlagClient({ ...baseConfig, storage }); - await client.init(); - - expect(store['testapp-feature-flags']).toBeDefined(); - expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true }); - client.stop(); - }); - - it('should restore flags from storage on creation', () => { - const store: Record = { - 'testapp-feature-flags': JSON.stringify({ restored: true }), - }; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - const client = createFeatureFlagClient({ ...baseConfig, storage }); - expect(client.isEnabled('restored')).toBe(true); - }); - - it('should stop polling on stop()', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ flags: {} }), - }) - ); - - const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 }); - await client.init(); - client.stop(); - - expect(client.isEnabled('anything')).toBe(false); - expect(client.getAllFlags()).toEqual({}); - }); -}); diff --git a/vendor/bytelyst/feature-flag-client/src/client.ts b/vendor/bytelyst/feature-flag-client/src/client.ts deleted file mode 100644 index 98c2fe1..0000000 --- a/vendor/bytelyst/feature-flag-client/src/client.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Browser/React Native-safe feature flag client for platform-service. - * - * Supports two modes: - * 1. **Polling** (default) — GET /flags/poll on a configurable interval - * 2. **Streaming** — SSE via GET /flags/stream for real-time updates - * - * Both modes support multi-variate evaluation via POST /flags/evaluate. - * No Node.js dependencies — uses globalThis.fetch and EventSource. - * - * @example - * ```ts - * import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; - * - * const flags = createFeatureFlagClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * platform: 'mobile', - * }); - * - * await flags.init({ userId: 'user-123' }); - * - * // Boolean check (legacy) - * if (flags.isEnabled('premium_body_viz')) { ... } - * - * // Multi-variate - * const color = flags.getValue('cta_color', '#000000'); - * const config = flags.getValue('rate_limits', { maxReqs: 100 }); - * - * // Listen for changes (SSE or polling refresh) - * const unsub = flags.onChange((key) => console.log(`${key} changed`)); - * ``` - */ - -import type { FeatureFlagClient, FeatureFlagClientConfig, EvaluationResult } from './types.js'; - -export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient { - const { - baseUrl, - productId, - platform, - pollIntervalMs = 5 * 60 * 1000, - storage, - storagePrefix, - useStreaming = false, - getAccessToken, - } = config; - - const prefix = storagePrefix ?? productId; - const BOOL_KEY = `${prefix}-feature-flags`; - const EVAL_KEY = `${prefix}-feature-evals`; - - let boolFlags: Record = {}; - let evaluations: Record = {}; - let initialized = false; - let intervalId: ReturnType | null = null; - // eslint-disable-next-line no-undef - let eventSource: InstanceType | null = null; - let userId: string | undefined; - const listeners = new Set<(flagKey: string) => void>(); - - // Restore from storage on creation - if (storage) { - try { - const cached = storage.getItem(BOOL_KEY); - if (cached) boolFlags = JSON.parse(cached); - } catch { - /* Ignore parse errors */ - } - try { - const cached = storage.getItem(EVAL_KEY); - if (cached) evaluations = JSON.parse(cached); - } catch { - /* Ignore parse errors */ - } - } - - function buildHeaders(): Record { - const requestId = - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - const headers: Record = { - 'x-product-id': productId, - 'x-request-id': requestId, - }; - if (getAccessToken) { - const token = getAccessToken(); - if (token) headers['authorization'] = `Bearer ${token}`; - } - return headers; - } - - function notifyListeners(flagKey: string): void { - for (const listener of listeners) { - try { - listener(flagKey); - } catch { - /* best-effort */ - } - } - } - - function persistToStorage(): void { - if (!storage) return; - try { - storage.setItem(BOOL_KEY, JSON.stringify(boolFlags)); - } catch { - /* non-fatal */ - } - try { - storage.setItem(EVAL_KEY, JSON.stringify(evaluations)); - } catch { - /* non-fatal */ - } - } - - async function fetchBoolFlags(): Promise { - try { - const parts = [`platform=${encodeURIComponent(platform)}`]; - if (userId) parts.push(`userId=${encodeURIComponent(userId)}`); - - const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, { - headers: buildHeaders(), - }); - - if (!res.ok) return; - - const data = (await res.json()) as { flags?: Record }; - const prev = boolFlags; - boolFlags = data.flags ?? {}; - - // Detect changes and notify - const allKeys = new Set([...Object.keys(prev), ...Object.keys(boolFlags)]); - for (const key of allKeys) { - if (prev[key] !== boolFlags[key]) notifyListeners(key); - } - - persistToStorage(); - } catch { - // Keep existing flags on network error - } - } - - async function fetchEvaluations(): Promise { - try { - const body: Record = { platform }; - if (userId) body.userId = userId; - - const res = await globalThis.fetch(`${baseUrl}/flags/evaluate`, { - method: 'POST', - headers: { ...buildHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) return; - - const data = (await res.json()) as { evaluations?: Record }; - const prev = evaluations; - evaluations = data.evaluations ?? {}; - - // Also update bool flags for backward compatibility - for (const [key, result] of Object.entries(evaluations)) { - const newBool = - result.reason !== 'off' && - result.reason !== 'prerequisite_failed' && - result.reason !== 'schedule_inactive' && - result.reason !== 'error'; - if (boolFlags[key] !== newBool) { - boolFlags[key] = newBool; - notifyListeners(key); - } else if (JSON.stringify(prev[key]?.value) !== JSON.stringify(result.value)) { - notifyListeners(key); - } - } - - persistToStorage(); - } catch { - // Keep existing evaluations on network error - } - } - - async function fetchAll(): Promise { - await Promise.all([fetchBoolFlags(), fetchEvaluations()]); - } - - function startStreaming(): void { - if (typeof globalThis.EventSource === 'undefined') { - // SSE not available (e.g. React Native) — fall back to polling - intervalId = setInterval(() => { - void fetchAll(); - }, pollIntervalMs); - return; - } - - const parts = [`productId=${encodeURIComponent(productId)}`]; - if (getAccessToken) { - const token = getAccessToken(); - if (token) parts.push(`token=${encodeURIComponent(token)}`); - } - const url = `${baseUrl}/flags/stream?${parts.join('&')}`; - eventSource = new globalThis.EventSource(url); - - eventSource.onmessage = event => { - try { - const data = JSON.parse(event.data) as { type?: string; flagKey?: string }; - if (data.type === 'flag_change' && data.flagKey) { - // Re-fetch all evaluations on any flag change - void fetchAll().then(() => notifyListeners(data.flagKey!)); - } - } catch { - /* Ignore parse errors */ - } - }; - - eventSource.onerror = () => { - // Reconnect handled automatically by EventSource spec - }; - } - - async function init(params?: { userId?: string }): Promise { - if (initialized) return; - initialized = true; - userId = params?.userId; - - await fetchAll(); - - if (useStreaming) { - startStreaming(); - } else { - intervalId = setInterval(() => { - void fetchAll(); - }, pollIntervalMs); - } - } - - function isEnabled(key: string): boolean { - return boolFlags[key] === true; - } - - function getValue>( - key: string, - defaultValue: T - ): T { - const result = evaluations[key]; - if (!result) return defaultValue; - if (result.reason === 'off' || result.reason === 'error') return defaultValue; - return result.value as T; - } - - function getEvaluation(key: string): EvaluationResult | undefined { - return evaluations[key]; - } - - function getAllFlags(): Readonly> { - return boolFlags; - } - - function getAllEvaluations(): Readonly> { - return evaluations; - } - - async function refresh(): Promise { - await fetchAll(); - } - - function onChange(listener: (flagKey: string) => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - } - - function stop(): void { - if (intervalId) clearInterval(intervalId); - intervalId = null; - if (eventSource) { - eventSource.close(); - eventSource = null; - } - boolFlags = {}; - evaluations = {}; - initialized = false; - userId = undefined; - listeners.clear(); - } - - return { - init, - isEnabled, - getValue, - getEvaluation, - getAllFlags, - getAllEvaluations, - refresh, - onChange, - stop, - }; -} diff --git a/vendor/bytelyst/feature-flag-client/src/index.ts b/vendor/bytelyst/feature-flag-client/src/index.ts deleted file mode 100644 index cd426b3..0000000 --- a/vendor/bytelyst/feature-flag-client/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { createFeatureFlagClient } from './client.js'; -export type { - FeatureFlagClient, - FeatureFlagClientConfig, - EvaluationContext, - EvaluationResult, -} from './types.js'; diff --git a/vendor/bytelyst/feature-flag-client/src/types.ts b/vendor/bytelyst/feature-flag-client/src/types.ts deleted file mode 100644 index cda332a..0000000 --- a/vendor/bytelyst/feature-flag-client/src/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Types for @bytelyst/feature-flag-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -// ── Evaluation types ──────────────────────────────────────────────────────── - -export interface EvaluationContext { - userId?: string; - platform?: string; - region?: string; - osVersion?: string; - appVersion?: string; - email?: string; - custom?: Record; -} - -export interface EvaluationResult { - key: string; - value: boolean | string | number | Record; - variationKey: string; - reason: string; -} - -// ── Config ────────────────────────────────────────────────────────────────── - -export interface FeatureFlagClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Platform string for the poll query (e.g. "mobile", "web"). */ - platform: string; - - /** Poll interval in milliseconds. Default: 5 minutes. */ - pollIntervalMs?: number; - - /** Optional persistent storage adapter for flag cache. */ - storage?: { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - }; - - /** Optional storage key prefix. Default: productId. */ - storagePrefix?: string; - - /** Use SSE for real-time updates instead of polling. Default: false. */ - useStreaming?: boolean; - - /** Auth token getter for authenticated requests. */ - getAccessToken?: () => string | null; -} - -// ── Client interface ──────────────────────────────────────────────────────── - -export interface FeatureFlagClient { - /** Initialize the client: fetch flags immediately and start polling/streaming. */ - init(params?: { userId?: string }): Promise; - - /** Check if a boolean feature flag is enabled. Returns false if not found. */ - isEnabled(key: string): boolean; - - /** Get the resolved value of a multi-variate flag. Returns defaultValue if not found. */ - getValue>( - key: string, - defaultValue: T - ): T; - - /** Get the full evaluation result for a flag. Returns undefined if not found. */ - getEvaluation(key: string): EvaluationResult | undefined; - - /** Get all currently cached boolean flags (legacy format). */ - getAllFlags(): Readonly>; - - /** Get all evaluation results (multi-variate format). */ - getAllEvaluations(): Readonly>; - - /** Force a refresh of feature flags. */ - refresh(): Promise; - - /** Register a listener for flag changes. Returns unsubscribe function. */ - onChange(listener: (flagKey: string) => void): () => void; - - /** Stop polling/streaming and reset state. */ - stop(): void; -} diff --git a/vendor/bytelyst/feature-flag-client/tsconfig.json b/vendor/bytelyst/feature-flag-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/feature-flag-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/feedback-client/package.json b/vendor/bytelyst/feedback-client/package.json deleted file mode 100644 index 3dafade..0000000 --- a/vendor/bytelyst/feedback-client/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bytelyst/feedback-client", - "version": "0.1.5", - "description": "TypeScript client for submitting user feedback with screenshots", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "peerDependencies": { - "zod": "^3.22.0" - }, - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/feedback-client/src/gdpr.test.ts b/vendor/bytelyst/feedback-client/src/gdpr.test.ts deleted file mode 100644 index c5a1077..0000000 --- a/vendor/bytelyst/feedback-client/src/gdpr.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * GDPR deletion test for feedback screenshots - * - * Tests the Right to be Forgotten compliance: - * 1. User submits feedback with screenshot - * 2. Admin deletes feedback and screenshot - * 3. Blob storage reference removed (actual deletion by lifecycle policy) - * - * TODO-5: GDPR deletion compliance test - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { createFeedbackClient, type FeedbackClient } from './index.js'; - -// Check if blob storage is available -const blobStorageAvailable = !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) -); - -const describeIntegration = blobStorageAvailable ? describe : describe.skip; - -describeIntegration('GDPR Deletion Compliance (TODO-5)', () => { - let client: FeedbackClient; - const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; - const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; - const adminToken = process.env.TEST_ADMIN_TOKEN || 'admin-token'; - - beforeAll(() => { - client = createFeedbackClient({ - baseUrl: testBaseUrl, - getAuthToken: () => testAuthToken, - }); - }); - - it('should delete feedback and screenshot on user request (GDPR)', async () => { - // Step 1: Submit feedback with screenshot - const testPngData = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, - 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, - 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, - 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]); - const testBlob = new Blob([testPngData], { type: 'image/png' }); - - const submitResult = await client.submitWithScreenshot({ - type: 'bug', - title: 'GDPR test feedback', - body: 'This feedback will be deleted per user request', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }); - - expect(submitResult.screenshotBlobPath).toBeDefined(); - const feedbackId = submitResult.id; - - // Step 2: Delete feedback (admin action) - const deleteRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(deleteRes.status).toBe(204); - - // Step 3: Verify feedback no longer exists - const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(getRes.status).toBe(404); - - // Step 4: Verify screenshot reference is gone - // Note: Actual blob deletion is handled by Azure lifecycle policy - // This test verifies the database reference is removed - const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(screenshotRes.status).toBe(404); - - // GDPR deletion verified — blob will be purged by Azure lifecycle policy within 90 days - }, 30000); - - it('should delete only screenshot while keeping feedback (partial deletion)', async () => { - // Submit feedback with screenshot - const testBlob = new Blob(['test'], { type: 'image/png' }); - const submitResult = await client.submitWithScreenshot({ - type: 'feature', - title: 'Partial deletion test', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }); - - const feedbackId = submitResult.id; - - // Delete just the screenshot - const deleteScreenshotRes = await fetch( - `${testBaseUrl}/api/feedback/${feedbackId}/screenshot`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - } - ); - expect(deleteScreenshotRes.status).toBe(204); - - // Verify feedback still exists but screenshot is gone - const getRes = await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - headers: { Authorization: `Bearer ${adminToken}` }, - }); - expect(getRes.status).toBe(200); - - const feedback = await getRes.json(); - expect(feedback.screenshotBlobPath).toBeNull(); - - // Cleanup - await fetch(`${testBaseUrl}/api/feedback/${feedbackId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - }); - }, 30000); -}); - -describe('GDPR Compliance Checklist', () => { - it('documents GDPR requirements', () => { - const gdprRequirements = [ - '✅ User can request deletion of their feedback', - '✅ Admin can delete feedback and screenshots', - '✅ Screenshot blob reference is removed from database', - '✅ Feedback data removed from Cosmos DB', - '✅ Azure lifecycle policy purges blob within 90 days', - '✅ Deletion is irreversible (no soft-delete)', - ]; - - // All GDPR requirements satisfied — see gdprRequirements array above - - expect(gdprRequirements.length).toBeGreaterThan(0); - }); -}); diff --git a/vendor/bytelyst/feedback-client/src/index.test.ts b/vendor/bytelyst/feedback-client/src/index.test.ts deleted file mode 100644 index dbbe693..0000000 --- a/vendor/bytelyst/feedback-client/src/index.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FeedbackClient } from './index.js'; -import type { ApiClient } from '@bytelyst/api-client'; - -describe('FeedbackClient', () => { - const mockApi: Partial = { - fetch: vi.fn(), - }; - - const createClient = () => new FeedbackClient(mockApi as ApiClient); - - it('should submit feedback without screenshot', async () => { - const client = createClient(); - const mockResponse = { - id: 'fb_123', - productId: 'test', - userId: 'user_123', - type: 'bug', - title: 'Test bug', - status: 'new', - createdAt: new Date().toISOString(), - }; - - mockApi.fetch = vi.fn().mockResolvedValue(mockResponse); - - const result = await client.submitWithScreenshot({ - type: 'bug', - title: 'Test bug', - body: 'Description', - }); - - expect(result).toEqual(mockResponse); - expect(mockApi.fetch).toHaveBeenCalledWith( - '/api/feedback', - expect.objectContaining({ - method: 'POST', - }) - ); - }); - - it('should throw if captureAndSubmit called without screenshot', async () => { - const client = createClient(); - - await expect(client.captureAndSubmit({ type: 'bug', title: 'Test' })).rejects.toThrow( - 'Screenshot capture only available in browser environment' - ); - }); -}); diff --git a/vendor/bytelyst/feedback-client/src/index.ts b/vendor/bytelyst/feedback-client/src/index.ts deleted file mode 100644 index 52f1876..0000000 --- a/vendor/bytelyst/feedback-client/src/index.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Feedback Client — TypeScript SDK for user feedback with screenshots - * - * @module @bytelyst/feedback-client - */ - -import { createApiClient, type ApiClient } from '@bytelyst/api-client'; - -export interface FeedbackClientConfig { - baseUrl: string; - getAuthToken: () => string | null; -} - -export interface DeviceContext { - osVersion: string; - appVersion: string; - deviceModel: string; - screenResolution: string; - locale: string; -} - -export interface SubmitFeedbackParams { - type: 'bug' | 'feature' | 'praise' | 'other'; - title: string; - body?: string; - screen?: string; - rating?: number; - appVersion?: string; - platform?: 'web' | 'ios' | 'android'; - screenshot?: { - blob: Blob; - contentType: 'image/png' | 'image/jpeg' | 'image/webp'; - }; - deviceContext?: DeviceContext; -} - -export interface SasResponse { - blobPath: string; - uploadUrl: string; - expiresIn: number; - maxSizeBytes: number; -} - -export interface FeedbackResponse { - id: string; - productId: string; - userId: string; - type: string; - title: string; - status: string; - createdAt: string; - screenshotBlobPath?: string; -} - -export type UploadProgressCallback = (loaded: number, total: number) => void; - -export interface ScreenshotOptions { - /** For web: CSS selector of element to capture. If omitted, captures viewport */ - selector?: string; - /** Image format */ - format?: 'png' | 'jpeg' | 'webp'; - /** JPEG quality (0-1), only used for jpeg format */ - quality?: number; -} - -export interface CaptureResult { - blob: Blob; - contentType: 'image/png' | 'image/jpeg' | 'image/webp'; - width: number; - height: number; -} - -/** - * Create a feedback client for submitting user feedback with optional screenshots - */ -export function createFeedbackClient(config: FeedbackClientConfig) { - const api = createApiClient({ - baseUrl: config.baseUrl, - getToken: config.getAuthToken, - }); - - return new FeedbackClient(api); -} - -/** - * Feedback client class for submitting feedback with screenshots - */ -export class FeedbackClient { - constructor(private api: ApiClient) {} - - /** - * Submit feedback with optional screenshot - * - * Flow: - * 1. If screenshot provided, get SAS URL - * 2. Upload screenshot to blob storage - * 3. Submit feedback with screenshot metadata - */ - async submitWithScreenshot( - params: SubmitFeedbackParams, - onProgress?: UploadProgressCallback - ): Promise { - let screenshotMeta: { blobPath: string; contentType: string; sizeBytes: number } | undefined; - - // Step 1 & 2: Handle screenshot upload if provided - if (params.screenshot) { - const sas = await this.generateSasUrl(params.screenshot.contentType); - - await this.uploadScreenshot(sas.uploadUrl, params.screenshot.blob, onProgress); - - screenshotMeta = { - blobPath: sas.blobPath, - contentType: params.screenshot.contentType, - sizeBytes: params.screenshot.blob.size, - }; - } - - // Step 3: Submit feedback - const response = await this.api.fetch('/api/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: params.type, - title: params.title, - body: params.body, - screen: params.screen, - rating: params.rating, - appVersion: params.appVersion, - platform: params.platform, - screenshotBlobPath: screenshotMeta?.blobPath, - screenshotContentType: screenshotMeta?.contentType as - | 'image/png' - | 'image/jpeg' - | 'image/webp', - screenshotSizeBytes: screenshotMeta?.sizeBytes, - deviceContext: params.deviceContext, - }), - }); - - return response; - } - - /** - * Generate SAS URL for screenshot upload - */ - private async generateSasUrl( - contentType: 'image/png' | 'image/jpeg' | 'image/webp' - ): Promise { - const response = await this.api.fetch('/api/feedback/sas', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ contentType }), - }); - return response; - } - - /** - * Upload screenshot directly to Azure Blob - */ - private async uploadScreenshot( - uploadUrl: string, - blob: Blob, - onProgress?: UploadProgressCallback - ): Promise { - // Use XMLHttpRequest for progress tracking if callback provided - if (onProgress && typeof window !== 'undefined') { - return this.uploadWithProgress(uploadUrl, blob, onProgress); - } - - // Simple fetch upload - const response = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': blob.type, - 'x-ms-blob-type': 'BlockBlob', - }, - body: blob, - }); - - if (!response.ok) { - throw new Error(`Upload failed: ${response.status} ${response.statusText}`); - } - } - - /** - * Upload with progress tracking using XMLHttpRequest - */ - private uploadWithProgress( - uploadUrl: string, - blob: Blob, - onProgress: UploadProgressCallback - ): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (event: ProgressEvent) => { - if (event.lengthComputable) { - onProgress(event.loaded, event.total); - } - }); - - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); - } - }); - - xhr.addEventListener('error', () => { - reject(new Error('Upload failed: Network error')); - }); - - xhr.open('PUT', uploadUrl); - xhr.setRequestHeader('Content-Type', blob.type); - xhr.setRequestHeader('x-ms-blob-type', 'BlockBlob'); - xhr.send(blob); - }); - } - - /** - * Capture screenshot of current page or element (Web only) - * - * Uses native getDisplayMedia for screen capture or html2canvas-style - * DOM serialization for element capture. - */ - async captureScreenshot(options: ScreenshotOptions = {}): Promise { - // Check if running in browser - if (typeof window === 'undefined' || typeof document === 'undefined') { - throw new Error('Screenshot capture only available in browser environment'); - } - - const format = options.format || 'png'; - const mimeType = - format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp'; - - // If selector provided, capture specific element - if (options.selector) { - return this.captureElement(options.selector, mimeType, options.quality); - } - - // Otherwise capture full screen using getDisplayMedia - return this.captureScreen(mimeType); - } - - /** - * Capture entire screen using getDisplayMedia - */ - private async captureScreen(mimeType: string): Promise { - try { - // Request screen capture permission - const stream = await navigator.mediaDevices.getDisplayMedia({ - video: true, - audio: false, - }); - - // Create video element to capture frame - const video = document.createElement('video'); - video.srcObject = stream; - - // Wait for video to load - await new Promise((resolve, reject) => { - video.onloadedmetadata = () => { - video.play(); - resolve(); - }; - video.onerror = () => reject(new Error('Failed to load video stream')); - // Timeout after 5 seconds - setTimeout(() => reject(new Error('Video load timeout')), 5000); - }); - - // Draw to canvas - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); - - ctx.drawImage(video, 0, 0); - - // Stop all tracks - stream.getTracks().forEach(track => track.stop()); - - // Convert to blob - const quality = mimeType === 'image/jpeg' ? 0.9 : undefined; - const blob = await new Promise((resolve, reject) => { - canvas.toBlob( - b => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), - mimeType, - quality - ); - }); - - return { - blob, - contentType: mimeType as 'image/png' | 'image/jpeg' | 'image/webp', - width: canvas.width, - height: canvas.height, - }; - } catch (err) { - throw new Error( - `Screen capture failed: ${err instanceof Error ? err.message : String(err)}`, - { cause: err } - ); - } - } - - /** - * Capture specific DOM element using html-to-image approach. - * `mimeType` and `quality` are accepted for API parity with captureScreen() - * but are not yet honoured here (this path always returns the fallback PNG). - */ - private async captureElement( - selector: string, - _mimeType: string, - _quality?: number - ): Promise { - const element = document.querySelector(selector); - if (!element) { - throw new Error(`Element not found: ${selector}`); - } - - // Use html-to-image or similar approach - // For now, we'll use a simple canvas-based approach for visible elements - const rect = element.getBoundingClientRect(); - - // Create canvas - const canvas = document.createElement('canvas'); - canvas.width = rect.width; - canvas.height = rect.height; - const ctx = canvas.getContext('2d'); - if (!ctx) throw new Error('Failed to get canvas context'); - - // Try to use html2canvas-style approach if available, otherwise warn - // This is a simplified implementation - throw new Error( - 'Element capture requires html2canvas library. ' + - 'Please install: npm install html2canvas ' + - 'Then use: html2canvas(element).then(canvas => canvas.toBlob(...))' - ); - } - - /** - * Capture and submit feedback in one operation - * - * @example - * // Capture full screen and submit - * const result = await client.captureAndSubmit({ - * type: 'bug', - * title: 'Something is broken', - * body: 'Description of the issue' - * }); - * - * @example - * // Capture specific element - * const result = await client.captureAndSubmit({ - * type: 'bug', - * title: 'Button not working', - * body: 'The submit button is unresponsive' - * }, { - * selector: '#submit-button' - * }); - */ - async captureAndSubmit( - params: Omit, - screenshotOptions?: ScreenshotOptions, - onProgress?: UploadProgressCallback - ): Promise { - // Capture screenshot - const capture = await this.captureScreenshot(screenshotOptions); - - // Submit with captured screenshot - return this.submitWithScreenshot( - { - ...params, - screenshot: { - blob: capture.blob, - contentType: capture.contentType, - }, - }, - onProgress - ); - } -} diff --git a/vendor/bytelyst/feedback-client/src/integration.test.ts b/vendor/bytelyst/feedback-client/src/integration.test.ts deleted file mode 100644 index 3451254..0000000 --- a/vendor/bytelyst/feedback-client/src/integration.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Integration tests for feedback screenshot flow - * - * These tests verify the complete flow: - * 1. Generate SAS URL for upload - * 2. Upload screenshot to blob storage - * 3. Submit feedback with screenshot metadata - * 4. Retrieve feedback with screenshot URL - * - * NOTE: Requires blob storage to be available in test environment. - * Tests are auto-skipped when AZURE_BLOB_CONNECTION_STRING is not set. - * In CI, set AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME+KEY. - */ - -import { describe, it, expect, beforeAll } from 'vitest'; -import { createFeedbackClient, type FeedbackClient } from './index.js'; - -// Check if blob storage is available -const blobStorageAvailable = !!( - process.env.AZURE_BLOB_CONNECTION_STRING || - (process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY) -); - -const describeIntegration = blobStorageAvailable ? describe : describe.skip; - -describeIntegration('Feedback Screenshot Integration', () => { - let client: FeedbackClient; - const testBaseUrl = process.env.TEST_API_URL || 'http://localhost:4003'; - const testAuthToken = process.env.TEST_AUTH_TOKEN || 'test-token'; - - beforeAll(() => { - client = createFeedbackClient({ - baseUrl: testBaseUrl, - getAuthToken: () => testAuthToken, - }); - }); - - it('should complete full screenshot submission flow', async () => { - // Create a test image blob (1x1 pixel PNG) - const testPngData = new Uint8Array([ - 0x89, - 0x50, - 0x4e, - 0x47, - 0x0d, - 0x0a, - 0x1a, - 0x0a, // PNG signature - 0x00, - 0x00, - 0x00, - 0x0d, - 0x49, - 0x48, - 0x44, - 0x52, // IHDR chunk - 0x00, - 0x00, - 0x00, - 0x01, - 0x00, - 0x00, - 0x00, - 0x01, // 1x1 pixel - 0x08, - 0x02, - 0x00, - 0x00, - 0x00, - 0x90, - 0x77, - 0x53, - 0xde, - 0x00, - 0x00, - 0x00, - 0x0c, - 0x49, - 0x44, - 0x41, // IDAT chunk - 0x54, - 0x08, - 0xd7, - 0x63, - 0xf8, - 0xcf, - 0xc0, - 0x00, - 0x00, - 0x03, - 0x01, - 0x01, - 0x00, - 0x18, - 0xdd, - 0x8d, - 0xb4, - 0x00, - 0x00, - 0x00, - 0x00, - 0x49, - 0x45, - 0x4e, // IEND chunk - 0x44, - 0xae, - 0x42, - 0x60, - 0x82, - ]); - const testBlob = new Blob([testPngData], { type: 'image/png' }); - - // Submit feedback with screenshot - const result = await client.submitWithScreenshot({ - type: 'bug', - title: 'Integration test screenshot', - body: 'This is a test feedback with screenshot', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - deviceContext: { - osVersion: 'Test OS 1.0', - appVersion: '1.0.0', - deviceModel: 'Test Device', - screenResolution: '1920x1080', - locale: 'en-US', - }, - }); - - // Verify response - expect(result).toBeDefined(); - expect(result.id).toBeDefined(); - expect(result.type).toBe('bug'); - expect(result.title).toBe('Integration test screenshot'); - expect(result.status).toBe('new'); - expect(result.screenshotBlobPath).toBeDefined(); - expect(result.screenshotBlobPath).toContain('feedbackScreenshots'); - - // TODO: Verify screenshot can be retrieved via admin API - // const screenshotRes = await fetch(`${testBaseUrl}/api/feedback/${result.id}/screenshot`, { - // headers: { Authorization: `Bearer ${adminToken}` }, - // }); - // expect(screenshotRes.ok).toBe(true); - }, 30000); // 30 second timeout for upload - - it('should submit feedback without screenshot', async () => { - const result = await client.submitWithScreenshot({ - type: 'feature', - title: 'Integration test without screenshot', - body: 'This is a test feedback without screenshot', - }); - - expect(result).toBeDefined(); - expect(result.id).toBeDefined(); - expect(result.type).toBe('feature'); - expect(result.screenshotBlobPath).toBeUndefined(); - }); - - it('should track upload progress', async () => { - const progressCallbacks: number[] = []; - const testBlob = new Blob(['test data'], { type: 'image/png' }); - - try { - await client.submitWithScreenshot( - { - type: 'bug', - title: 'Progress test', - screenshot: { - blob: testBlob, - contentType: 'image/png', - }, - }, - (loaded, _total) => { - progressCallbacks.push(loaded); - } - ); - } catch { - // Expected to fail with invalid PNG, but progress should still be called - } - - // Progress callback may or may not be called depending on upload speed - // Just verify the callback mechanism exists - expect(progressCallbacks).toBeDefined(); - }); -}); - -describe('Feedback Client Unit Tests (no blob storage required)', () => { - it('should validate screenshot content types', () => { - const validTypes = ['image/png', 'image/jpeg', 'image/webp']; - const invalidTypes = ['image/gif', 'application/pdf', 'text/plain']; - - for (const type of validTypes) { - expect(type).toMatch(/^image\/(png|jpeg|webp)$/); - } - - for (const type of invalidTypes) { - expect(type).not.toMatch(/^image\/(png|jpeg|webp)$/); - } - }); - - it('should enforce 5MB size limit', () => { - const maxSize = 5 * 1024 * 1024; // 5MB - const underLimit = maxSize - 1; - const overLimit = maxSize + 1; - - expect(underLimit).toBeLessThanOrEqual(maxSize); - expect(overLimit).toBeGreaterThan(maxSize); - }); -}); diff --git a/vendor/bytelyst/feedback-client/tsconfig.json b/vendor/bytelyst/feedback-client/tsconfig.json deleted file mode 100644 index ce78e59..0000000 --- a/vendor/bytelyst/feedback-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/field-encrypt/package.json b/vendor/bytelyst/field-encrypt/package.json deleted file mode 100644 index 0a184be..0000000 --- a/vendor/bytelyst/field-encrypt/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@bytelyst/field-encrypt", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@bytelyst/errors": "workspace:*" - }, - "peerDependencies": { - "@azure/keyvault-keys": ">=4.8.0", - "@azure/identity": ">=4.0.0", - "zod": ">=3.22.0" - }, - "peerDependenciesMeta": { - "@azure/keyvault-keys": { - "optional": true - }, - "@azure/identity": { - "optional": true - } - }, - "devDependencies": { - "vitest": "^3.0.0", - "zod": "^3.24.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/field-encrypt/src/aes-gcm.ts b/vendor/bytelyst/field-encrypt/src/aes-gcm.ts deleted file mode 100644 index 4eedc37..0000000 --- a/vendor/bytelyst/field-encrypt/src/aes-gcm.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @bytelyst/field-encrypt — AES-256-GCM primitives - * - * Low-level encrypt/decrypt using Node.js native crypto. - * All higher-level APIs delegate to these functions. - */ - -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import type { EncryptedField } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; -const KEY_BYTES = 32; - -/** - * Encrypt a plaintext string with AES-256-GCM. - * - * @param plaintext - UTF-8 string to encrypt - * @param key - 32-byte AES key - * @param dekId - DEK identifier stored in the output - * @param aad - Optional additional authenticated data (e.g., userId + context) - * @returns EncryptedField object ready for Cosmos/SQLite storage - */ -export function encryptField( - plaintext: string, - key: Buffer, - dekId: string, - aad?: string -): EncryptedField { - if (key.length !== KEY_BYTES) { - throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); - } - - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, key, iv); - - if (aad) { - cipher.setAAD(Buffer.from(aad, 'utf8')); - } - - let encrypted = cipher.update(plaintext, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: encrypted, - iv: iv.toString('hex'), - tag: authTag.toString('hex'), - dekId, - }; -} - -/** - * Decrypt an EncryptedField back to plaintext. - * - * @param field - EncryptedField object - * @param key - 32-byte AES key (must match the key used to encrypt) - * @param aad - Optional AAD (must match the AAD used during encryption) - * @returns Decrypted UTF-8 string - * @throws Error if authentication tag verification fails (tampered data) - */ -export function decryptField(field: EncryptedField, key: Buffer, aad?: string): string { - if (key.length !== KEY_BYTES) { - throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`); - } - - const iv = Buffer.from(field.iv, 'hex'); - const authTag = Buffer.from(field.tag, 'hex'); - const decipher = createDecipheriv(ALGORITHM, key, iv); - - decipher.setAuthTag(authTag); - - if (aad) { - decipher.setAAD(Buffer.from(aad, 'utf8')); - } - - let decrypted = decipher.update(field.ct, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; -} - -/** Generate a random 32-byte AES-256 key. */ -export function generateAesKey(): Buffer { - return randomBytes(KEY_BYTES); -} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts b/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts deleted file mode 100644 index 4d322aa..0000000 --- a/vendor/bytelyst/field-encrypt/src/dek-store-cosmos.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @bytelyst/field-encrypt — Cosmos DB DEK store - * - * Production DEK store backed by Azure Cosmos DB. - * Container: `_encryption_keys` (partition key: /dekId) - * - * Each WrappedDek is stored as a document with dekId as both id and partition key. - */ - -import type { Container } from '@azure/cosmos'; -import type { DekStore, WrappedDek } from './types.js'; - -interface CosmosDekDoc { - id: string; - dekId: string; - wrappedKey: string; - mekVersion: string; - createdAt: string; -} - -export class CosmosDekStore implements DekStore { - constructor(private readonly container: Container) {} - - async get(dekId: string): Promise { - try { - const { resource } = await this.container.item(dekId, dekId).read(); - if (!resource) return null; - return { - dekId: resource.dekId, - wrappedKey: resource.wrappedKey, - mekVersion: resource.mekVersion, - createdAt: resource.createdAt, - }; - } catch (err: unknown) { - if (isNotFound(err)) return null; - throw err; - } - } - - async put(dek: WrappedDek): Promise { - const doc: CosmosDekDoc = { - id: dek.dekId, - dekId: dek.dekId, - wrappedKey: dek.wrappedKey, - mekVersion: dek.mekVersion, - createdAt: dek.createdAt, - }; - await this.container.items.upsert(doc); - } - - async listIds(): Promise { - const { resources } = await this.container.items - .query<{ dekId: string }>('SELECT c.dekId FROM c') - .fetchAll(); - return resources.map(r => r.dekId); - } - - async delete(dekId: string): Promise { - try { - await this.container.item(dekId, dekId).delete(); - } catch (err: unknown) { - if (isNotFound(err)) return; // Already deleted — idempotent - throw err; - } - } -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === 'object' && - err !== null && - 'code' in err && - (err as { code: number }).code === 404 - ); -} diff --git a/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts b/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts deleted file mode 100644 index 79aabc3..0000000 --- a/vendor/bytelyst/field-encrypt/src/dek-store-memory.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @bytelyst/field-encrypt — In-memory DEK store - * - * Default DEK store for dev/test. Production should use a Cosmos-backed store. - */ - -import type { DekStore, WrappedDek } from './types.js'; - -export class MemoryDekStore implements DekStore { - private readonly deks = new Map(); - - async get(dekId: string): Promise { - return this.deks.get(dekId) ?? null; - } - - async put(dek: WrappedDek): Promise { - this.deks.set(dek.dekId, dek); - } - - async listIds(): Promise { - return [...this.deks.keys()]; - } - - async delete(dekId: string): Promise { - this.deks.delete(dekId); - } -} diff --git a/vendor/bytelyst/field-encrypt/src/envelope.ts b/vendor/bytelyst/field-encrypt/src/envelope.ts deleted file mode 100644 index a062db3..0000000 --- a/vendor/bytelyst/field-encrypt/src/envelope.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @bytelyst/field-encrypt — Envelope encryption - * - * DEK lifecycle: generate → wrap with MEK → store. - * On use: load wrapped DEK → unwrap with MEK → cache → use for AES-GCM. - */ - -import type { KeyProvider, DekStore, WrappedDek } from './types.js'; -import { generateAesKey } from './aes-gcm.js'; -import { DekCache } from './key-cache.js'; - -/** - * Build a deterministic DEK ID from userId + context. - * Format: `dek_{userId}_{context}` - */ -export function buildDekId(userId: string, context: string): string { - return `dek_${userId}_${context}`; -} - -/** - * Get or create a DEK for the given scope. - * - * 1. Check cache → return if found - * 2. Check DEK store → unwrap + cache if found - * 3. Generate new DEK → wrap → store → cache → return - */ -export async function getOrCreateDek( - dekId: string, - keyProvider: KeyProvider, - dekStore: DekStore, - cache: DekCache -): Promise { - // 1. Cache hit - const cached = cache.get(dekId); - if (cached) { - cache.recordHit(); - return cached; - } - cache.recordMiss(); - - // 2. DEK store hit — unwrap + cache - const stored = await dekStore.get(dekId); - if (stored) { - const dek = await keyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); - cache.set(dekId, dek); - return dek; - } - - // 3. Generate new DEK → wrap → store → cache - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await keyProvider.wrapKey(dek); - - const wrappedDek: WrappedDek = { - dekId, - wrappedKey, - mekVersion, - createdAt: new Date().toISOString(), - }; - await dekStore.put(wrappedDek); - - cache.set(dekId, dek); - return dek; -} - -/** - * Re-wrap all DEKs after MEK rotation. - * - * Reads each wrapped DEK, unwraps with old MEK, wraps with new MEK, stores updated. - */ -export async function rewrapAllDeks( - oldKeyProvider: KeyProvider, - newKeyProvider: KeyProvider, - dekStore: DekStore, - cache: DekCache, - onProgress?: (completed: number, total: number) => void -): Promise { - const dekIds = await dekStore.listIds(); - let completed = 0; - - for (const dekId of dekIds) { - const stored = await dekStore.get(dekId); - if (!stored) continue; - - // Unwrap with old MEK - const rawDek = await oldKeyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion); - - // Wrap with new MEK - const { wrappedKey, mekVersion } = await newKeyProvider.wrapKey(rawDek); - - // Store updated wrapped DEK - const updated: WrappedDek = { - dekId, - wrappedKey, - mekVersion, - createdAt: stored.createdAt, - }; - await dekStore.put(updated); - - // Invalidate cache entry so it gets re-unwrapped with new MEK next time - cache.invalidate(dekId); - - completed++; - onProgress?.(completed, dekIds.length); - } - - return completed; -} diff --git a/vendor/bytelyst/field-encrypt/src/field-encryptor.ts b/vendor/bytelyst/field-encrypt/src/field-encryptor.ts deleted file mode 100644 index 50f7e32..0000000 --- a/vendor/bytelyst/field-encrypt/src/field-encryptor.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * @bytelyst/field-encrypt — FieldEncryptor - * - * Main API — wires key provider + DEK store + cache + AES-GCM. - * Product backends create a singleton via createFieldEncryptor(). - */ - -import type { - EncryptedField, - FieldEncryptContext, - FieldEncryptorConfig, - KeyProvider, - DekStore, -} from './types.js'; -import { encryptField, decryptField } from './aes-gcm.js'; -import { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; -import { DekCache } from './key-cache.js'; -import { MemoryDekStore } from './dek-store-memory.js'; -import { MemoryKeyProvider } from './key-provider-memory.js'; -import { EnvKeyProvider } from './key-provider-env.js'; -import { AkvKeyProvider } from './key-provider-akv.js'; -import { isEncryptedField } from './guards.js'; - -export class FieldEncryptor { - private readonly keyProvider: KeyProvider; - private readonly dekStore: DekStore; - private readonly cache: DekCache; - - constructor(config: FieldEncryptorConfig) { - this.keyProvider = resolveKeyProvider(config); - this.dekStore = config.dekStore ?? new MemoryDekStore(); - this.cache = new DekCache( - config.dekCacheTtlMs ?? 15 * 60 * 1000, - config.dekCacheMaxSize ?? 1000 - ); - } - - /** - * Encrypt a plaintext string. - * - * Automatically gets or creates a DEK scoped to the userId + context. - */ - async encrypt(plaintext: string, ctx: FieldEncryptContext): Promise { - const dekId = buildDekId(ctx.userId, ctx.context); - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); - return encryptField(plaintext, dek, dekId, aad); - } - - /** - * Decrypt an EncryptedField back to plaintext. - */ - async decrypt(field: EncryptedField, ctx: FieldEncryptContext): Promise { - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); - return decryptField(field, dek, aad); - } - - /** - * Encrypt multiple fields in a single call (optimized — single DEK lookup). - */ - async encryptBatch(plaintexts: string[], ctx: FieldEncryptContext): Promise { - const dekId = buildDekId(ctx.userId, ctx.context); - const aad = `${ctx.userId}:${ctx.context}`; - const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); - return plaintexts.map(pt => encryptField(pt, dek, dekId, aad)); - } - - /** - * Decrypt multiple EncryptedFields in a single call. - * - * Groups by dekId for efficient DEK lookup. - */ - async decryptBatch(fields: EncryptedField[], ctx: FieldEncryptContext): Promise { - const aad = `${ctx.userId}:${ctx.context}`; - const dekMap = new Map(); - - const results: string[] = []; - for (const field of fields) { - let dek = dekMap.get(field.dekId); - if (!dek) { - dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); - dekMap.set(field.dekId, dek); - } - results.push(decryptField(field, dek, aad)); - } - - return results; - } - - /** - * Check if a value is an EncryptedField. - */ - isEncrypted(value: unknown): value is EncryptedField { - return isEncryptedField(value); - } - - /** - * Re-wrap all DEKs after MEK rotation. - */ - async rewrapDeks( - newKeyProvider: KeyProvider, - onProgress?: (completed: number, total: number) => void - ): Promise { - return rewrapAllDeks(this.keyProvider, newKeyProvider, this.dekStore, this.cache, onProgress); - } - - /** DEK cache hit rate (0-100). */ - get cacheHitRate(): number { - return this.cache.hitRate; - } - - /** Number of cached DEKs. */ - get cacheSize(): number { - return this.cache.size; - } - - /** Reset cache statistics. */ - resetCacheStats(): void { - this.cache.resetStats(); - } - - /** Clear DEK cache (e.g., on shutdown). */ - clearCache(): void { - this.cache.clear(); - } -} - -function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { - switch (config.keyProvider) { - case 'memory': - return new MemoryKeyProvider(); - - case 'env': { - const key = config.encryptionKey; - if (!key) { - throw new Error('FieldEncryptor: "env" key provider requires encryptionKey (hex string)'); - } - return new EnvKeyProvider(key); - } - - case 'akv': { - if (!config.keyVaultUrl) { - throw new Error('FieldEncryptor: "akv" key provider requires keyVaultUrl'); - } - if (!config.mekName) { - throw new Error('FieldEncryptor: "akv" key provider requires mekName'); - } - return new AkvKeyProvider(config.keyVaultUrl, config.mekName); - } - - default: - throw new Error(`FieldEncryptor: unknown key provider "${config.keyProvider}"`); - } -} - -/** - * No-op encryptor — stores/returns plaintext unchanged. - * - * Returned by createFieldEncryptor({ enabled: false, ... }). - * All repositories continue calling encrypt()/decrypt() without branching. - */ -export class NullFieldEncryptor extends FieldEncryptor { - constructor() { - // Use memory provider — it will never be called, but satisfies the constructor - super({ keyProvider: 'memory' }); - } - - override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise { - // Return a sentinel object that looks encrypted but stores plaintext - return { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: plaintext, - iv: 'disabled', - tag: 'disabled', - dekId: 'disabled', - }; - } - - override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise { - // If encryption was disabled, ct contains the plaintext directly - return field.ct; - } - - override async encryptBatch( - plaintexts: string[], - ctx: FieldEncryptContext - ): Promise { - return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx))); - } - - override async decryptBatch( - fields: EncryptedField[], - ctx: FieldEncryptContext - ): Promise { - return Promise.all(fields.map(f => this.decrypt(f, ctx))); - } -} - -/** - * Create a FieldEncryptor instance. - * - * Typical usage (one per backend service): - * ```typescript - * const encryptor = createFieldEncryptor({ - * keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER ?? 'memory', - * mekName: 'lysnr-mek', - * keyVaultUrl: config.AZURE_KEYVAULT_URL, - * }); - * ``` - * - * To disable encryption globally or per-product: - * ```typescript - * const encryptor = createFieldEncryptor({ - * enabled: false, // ← no-op passthrough - * keyProvider: 'memory', - * }); - * ``` - */ -export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor { - if (config.enabled === false) { - return new NullFieldEncryptor(); - } - return new FieldEncryptor(config); -} diff --git a/vendor/bytelyst/field-encrypt/src/guards.ts b/vendor/bytelyst/field-encrypt/src/guards.ts deleted file mode 100644 index f8c63b8..0000000 --- a/vendor/bytelyst/field-encrypt/src/guards.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @bytelyst/field-encrypt — Type guards - * - * Utility to detect encrypted vs plaintext fields during migration. - */ - -import type { EncryptedField } from './types.js'; - -/** - * Check if a value is an EncryptedField. - * - * Use this in repositories to handle both encrypted and plaintext fields - * during the migration period. - */ -export function isEncryptedField(value: unknown): value is EncryptedField { - return ( - typeof value === 'object' && - value !== null && - '__encrypted' in value && - (value as Record).__encrypted === true && - 'v' in value && - 'ct' in value && - 'iv' in value && - 'tag' in value && - 'dekId' in value - ); -} diff --git a/vendor/bytelyst/field-encrypt/src/index.test.ts b/vendor/bytelyst/field-encrypt/src/index.test.ts deleted file mode 100644 index bff9494..0000000 --- a/vendor/bytelyst/field-encrypt/src/index.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * @bytelyst/field-encrypt — Tests - * - * ~35 tests covering AES-GCM, key providers, envelope, cache, - * field encryptor factory, type guards, and migration. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { randomBytes } from 'node:crypto'; -import { - createFieldEncryptor, - FieldEncryptor, - NullFieldEncryptor, - isEncryptedField, - encryptField, - decryptField, - generateAesKey, - buildDekId, - getOrCreateDek, - rewrapAllDeks, - DekCache, - MemoryDekStore, - CosmosDekStore, - MemoryKeyProvider, - EnvKeyProvider, - migrateDocuments, -} from './index.js'; -import type { EncryptedField, FieldEncryptContext } from './types.js'; - -// ── AES-256-GCM ───────────────────────────────────── - -describe('aes-gcm', () => { - const key = generateAesKey(); - const dekId = 'dek_test_ctx'; - - it('encrypt → decrypt roundtrip', () => { - const plaintext = 'Hello, sensitive data!'; - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('encrypt → decrypt with AAD', () => { - const plaintext = 'With AAD'; - const aad = 'user_123:transcripts'; - const encrypted = encryptField(plaintext, key, dekId, aad); - const decrypted = decryptField(encrypted, key, aad); - expect(decrypted).toBe(plaintext); - }); - - it('rejects decryption with wrong AAD', () => { - const encrypted = encryptField('secret', key, dekId, 'correct_aad'); - expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow(); - }); - - it('rejects decryption with tampered ciphertext', () => { - const encrypted = encryptField('secret', key, dekId); - const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' }; - expect(() => decryptField(tampered, key)).toThrow(); - }); - - it('rejects decryption with tampered auth tag', () => { - const encrypted = encryptField('secret', key, dekId); - const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) }; - expect(() => decryptField(tampered, key)).toThrow(); - }); - - it('handles empty string', () => { - const encrypted = encryptField('', key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(''); - }); - - it('handles unicode content', () => { - const plaintext = '日本語テスト 🔐 Ñoño'; - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('handles large payload (100 KB)', () => { - const plaintext = 'x'.repeat(100_000); - const encrypted = encryptField(plaintext, key, dekId); - const decrypted = decryptField(encrypted, key); - expect(decrypted).toBe(plaintext); - }); - - it('rejects wrong key size', () => { - const shortKey = randomBytes(16); - expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/); - }); - - it('produces correct EncryptedField shape', () => { - const encrypted = encryptField('test', key, dekId); - expect(encrypted.__encrypted).toBe(true); - expect(encrypted.v).toBe(1); - expect(encrypted.alg).toBe('aes-256-gcm'); - expect(encrypted.dekId).toBe(dekId); - expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars - expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars - expect(encrypted.ct.length).toBeGreaterThan(0); - }); - - it('generates unique IVs per encryption', () => { - const e1 = encryptField('same', key, dekId); - const e2 = encryptField('same', key, dekId); - expect(e1.iv).not.toBe(e2.iv); - expect(e1.ct).not.toBe(e2.ct); - }); -}); - -// ── Type guard ────────────────────────────────────── - -describe('isEncryptedField', () => { - it('returns true for valid EncryptedField', () => { - const field: EncryptedField = { - __encrypted: true, - v: 1, - alg: 'aes-256-gcm', - ct: 'abc', - iv: '012', - tag: '345', - dekId: 'dek_1', - }; - expect(isEncryptedField(field)).toBe(true); - }); - - it('returns false for string', () => { - expect(isEncryptedField('hello')).toBe(false); - }); - - it('returns false for null', () => { - expect(isEncryptedField(null)).toBe(false); - }); - - it('returns false for undefined', () => { - expect(isEncryptedField(undefined)).toBe(false); - }); - - it('returns false for object without __encrypted', () => { - expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false); - }); - - it('returns false for object with __encrypted: false', () => { - expect( - isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' }) - ).toBe(false); - }); -}); - -// ── Key providers ─────────────────────────────────── - -describe('MemoryKeyProvider', () => { - it('wrap → unwrap roundtrip', async () => { - const provider = new MemoryKeyProvider(); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); -}); - -describe('EnvKeyProvider', () => { - it('wrap → unwrap roundtrip with 64-char hex key', async () => { - const hexKey = randomBytes(32).toString('hex'); - const provider = new EnvKeyProvider(hexKey); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); - - it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => { - const provider = new EnvKeyProvider('my-dev-secret-key'); - const dek = generateAesKey(); - const { wrappedKey, mekVersion } = await provider.wrapKey(dek); - const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); - expect(unwrapped).toEqual(dek); - }); - - it('throws on empty key', () => { - expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/); - }); -}); - -// ── DEK cache ─────────────────────────────────────── - -describe('DekCache', () => { - let cache: DekCache; - - beforeEach(() => { - cache = new DekCache(1000, 3); // 1s TTL, max 3 entries - }); - - it('get returns null on miss', () => { - expect(cache.get('nonexistent')).toBeNull(); - }); - - it('set + get roundtrip', () => { - const key = generateAesKey(); - cache.set('dek_1', key); - expect(cache.get('dek_1')).toEqual(key); - }); - - it('expires entries after TTL', async () => { - const shortCache = new DekCache(50, 100); // 50ms TTL - const key = generateAesKey(); - shortCache.set('dek_1', key); - expect(shortCache.get('dek_1')).toEqual(key); - await new Promise(r => setTimeout(r, 60)); - expect(shortCache.get('dek_1')).toBeNull(); - }); - - it('evicts oldest on max size', () => { - cache.set('a', generateAesKey()); - cache.set('b', generateAesKey()); - cache.set('c', generateAesKey()); - // At max size (3), adding 'd' should evict 'a' - cache.set('d', generateAesKey()); - expect(cache.get('a')).toBeNull(); - expect(cache.get('d')).not.toBeNull(); - }); - - it('invalidate removes specific entry', () => { - cache.set('dek_1', generateAesKey()); - cache.invalidate('dek_1'); - expect(cache.get('dek_1')).toBeNull(); - }); - - it('tracks hit rate', () => { - cache.set('dek_1', generateAesKey()); - cache.recordHit(); - cache.recordHit(); - cache.recordMiss(); - expect(cache.hitRate).toBe(67); // 2/3 = 67% - }); -}); - -// ── Envelope ──────────────────────────────────────── - -describe('envelope', () => { - it('buildDekId produces correct format', () => { - expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts'); - }); - - it('getOrCreateDek creates and caches a new DEK', async () => { - const provider = new MemoryKeyProvider(); - const store = new MemoryDekStore(); - const cache = new DekCache(); - - const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache); - expect(dek).toHaveLength(32); - - // Should be in store - const stored = await store.get('dek_u1_ctx'); - expect(stored).not.toBeNull(); - - // Should be cached — second call should return same key - const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache); - expect(dek2).toEqual(dek); - }); - - it('rewrapAllDeks re-wraps with new provider', async () => { - const oldProvider = new MemoryKeyProvider(undefined, 'old-v1'); - const newProvider = new MemoryKeyProvider(undefined, 'new-v1'); - const store = new MemoryDekStore(); - const cache = new DekCache(); - - // Create 3 DEKs with old provider - await getOrCreateDek('dek_1', oldProvider, store, cache); - await getOrCreateDek('dek_2', oldProvider, store, cache); - await getOrCreateDek('dek_3', oldProvider, store, cache); - - // Re-wrap - const count = await rewrapAllDeks(oldProvider, newProvider, store, cache); - expect(count).toBe(3); - - // Verify new provider can unwrap - const stored = await store.get('dek_1'); - expect(stored).not.toBeNull(); - expect(stored!.mekVersion).toBe('new-v1'); - - const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion); - expect(unwrapped).toHaveLength(32); - }); -}); - -// ── FieldEncryptor (integration) ──────────────────── - -describe('FieldEncryptor', () => { - let encryptor: FieldEncryptor; - const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' }; - - beforeEach(() => { - encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - }); - - it('encrypt → decrypt roundtrip', async () => { - const encrypted = await encryptor.encrypt('Hello World', ctx); - expect(encrypted.__encrypted).toBe(true); - const decrypted = await encryptor.decrypt(encrypted, ctx); - expect(decrypted).toBe('Hello World'); - }); - - it('encryptBatch → decryptBatch roundtrip', async () => { - const plaintexts = ['one', 'two', 'three']; - const encrypted = await encryptor.encryptBatch(plaintexts, ctx); - expect(encrypted).toHaveLength(3); - const decrypted = await encryptor.decryptBatch(encrypted, ctx); - expect(decrypted).toEqual(plaintexts); - }); - - it('isEncrypted works via encryptor', async () => { - const encrypted = await encryptor.encrypt('test', ctx); - expect(encryptor.isEncrypted(encrypted)).toBe(true); - expect(encryptor.isEncrypted('plaintext')).toBe(false); - }); - - it('different users get different DEKs', async () => { - const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' }; - const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' }; - - const e1 = await encryptor.encrypt('same text', ctx1); - const e2 = await encryptor.encrypt('same text', ctx2); - - expect(e1.dekId).not.toBe(e2.dekId); - expect(e1.ct).not.toBe(e2.ct); - }); - - it('JSON-serialized array encryption roundtrip', async () => { - const transcript = [ - { role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' }, - { role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' }, - ]; - const serialized = JSON.stringify(transcript); - const encrypted = await encryptor.encrypt(serialized, ctx); - const decrypted = await encryptor.decrypt(encrypted, ctx); - expect(JSON.parse(decrypted)).toEqual(transcript); - }); -}); - -// ── Factory config validation ─────────────────────── - -describe('createFieldEncryptor config', () => { - it('throws on unknown provider', () => { - expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/); - }); - - it('throws on env provider without key', () => { - expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/); - }); - - it('throws on akv provider without vaultUrl', () => { - expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow( - /keyVaultUrl/ - ); - }); - - it('throws on akv provider without mekName', () => { - expect(() => - createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' }) - ).toThrow(/mekName/); - }); - - it('env provider works with hex key', async () => { - const hexKey = randomBytes(32).toString('hex'); - const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; - const encrypted = await enc.encrypt('secret', ctx); - const decrypted = await enc.decrypt(encrypted, ctx); - expect(decrypted).toBe('secret'); - }); -}); - -// ── Encryption toggle (enabled/disabled) ──────────── - -describe('enabled: false (NullFieldEncryptor)', () => { - const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; - - it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - expect(enc).toBeInstanceOf(NullFieldEncryptor); - }); - - it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => { - const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' }); - expect(enc).not.toBeInstanceOf(NullFieldEncryptor); - expect(enc).toBeInstanceOf(FieldEncryptor); - }); - - it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => { - const enc = createFieldEncryptor({ keyProvider: 'memory' }); - expect(enc).not.toBeInstanceOf(NullFieldEncryptor); - }); - - it('encrypt returns sentinel with plaintext in ct field', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const result = await enc.encrypt('hello world', ctx); - expect(result.__encrypted).toBe(true); - expect(result.ct).toBe('hello world'); - expect(result.iv).toBe('disabled'); - expect(result.tag).toBe('disabled'); - expect(result.dekId).toBe('disabled'); - }); - - it('decrypt returns plaintext from ct field', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const encrypted = await enc.encrypt('secret', ctx); - const decrypted = await enc.decrypt(encrypted, ctx); - expect(decrypted).toBe('secret'); - }); - - it('encryptBatch returns sentinels for all items', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const results = await enc.encryptBatch(['a', 'b', 'c'], ctx); - expect(results).toHaveLength(3); - expect(results[0].ct).toBe('a'); - expect(results[1].ct).toBe('b'); - expect(results[2].ct).toBe('c'); - }); - - it('decryptBatch returns plaintexts from ct fields', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const encrypted = await enc.encryptBatch(['x', 'y'], ctx); - const decrypted = await enc.decryptBatch(encrypted, ctx); - expect(decrypted).toEqual(['x', 'y']); - }); - - it('isEncrypted returns true for disabled sentinel', async () => { - const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); - const result = await enc.encrypt('test', ctx); - expect(enc.isEncrypted(result)).toBe(true); - }); -}); - -// ── Migration ─────────────────────────────────────── - -describe('migrateDocuments', () => { - it('encrypts plaintext fields and skips already-encrypted', async () => { - const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; - - const alreadyEncrypted = await encryptor.encrypt('old', ctx); - const docs = [ - { id: '1', body: 'plaintext note' }, - { id: '2', body: alreadyEncrypted }, - { id: '3', body: 'another note' }, - { id: '4', body: null }, - ]; - - const written: Array<{ id: string; body: EncryptedField }> = []; - - const result = await migrateDocuments({ - fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), - getId: doc => doc.id, - getField: doc => doc.body, - encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), - writeBack: async (doc, encrypted) => { - written.push({ id: doc.id, body: encrypted }); - }, - batchSize: 10, - }); - - expect(result.scanned).toBe(4); - expect(result.encrypted).toBe(2); // id 1 + id 3 - expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null) - expect(result.errors).toBe(0); - expect(written).toHaveLength(2); - }); - - it('dry run does not write', async () => { - const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); - const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; - - const docs = [{ id: '1', body: 'plaintext' }]; - let writeCount = 0; - - const result = await migrateDocuments({ - fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), - getId: doc => doc.id, - getField: doc => doc.body, - encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), - writeBack: async () => { - writeCount++; - }, - dryRun: true, - }); - - expect(result.encrypted).toBe(1); - expect(writeCount).toBe(0); - }); -}); - -// ── CosmosDekStore ────────────────────────────────── - -describe('CosmosDekStore', () => { - function createMockContainer() { - const docs = new Map>(); - - const container = { - item: (id: string, _pk: string) => ({ - read: async () => { - const resource = docs.get(id) as T | undefined; - if (!resource) { - const err = new Error('Not found') as Error & { code: number }; - err.code = 404; - throw err; - } - return { resource }; - }, - delete: async () => { - if (!docs.has(id)) { - const err = new Error('Not found') as Error & { code: number }; - err.code = 404; - throw err; - } - docs.delete(id); - }, - }), - items: { - upsert: async (doc: Record) => { - docs.set(doc.id as string, doc); - }, - query: (_sql: string) => ({ - fetchAll: async () => ({ - resources: [...docs.values()].map(d => ({ dekId: d.dekId })), - }), - }), - }, - }; - return container as unknown as import('@azure/cosmos').Container; - } - - it('put and get a DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - const dek = { - dekId: 'dek_test', - wrappedKey: 'aabbcc', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }; - await store.put(dek); - const got = await store.get('dek_test'); - expect(got).toEqual(dek); - }); - - it('get returns null for missing DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - const got = await store.get('nonexistent'); - expect(got).toBeNull(); - }); - - it('listIds returns all stored DEK IDs', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_a', - wrappedKey: '11', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.put({ - dekId: 'dek_b', - wrappedKey: '22', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - const ids = await store.listIds(); - expect(ids).toContain('dek_a'); - expect(ids).toContain('dek_b'); - expect(ids.length).toBe(2); - }); - - it('delete removes a DEK', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_del', - wrappedKey: '33', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.delete('dek_del'); - expect(await store.get('dek_del')).toBeNull(); - }); - - it('delete is idempotent (no error on missing)', async () => { - const store = new CosmosDekStore(createMockContainer()); - await expect(store.delete('nonexistent')).resolves.toBeUndefined(); - }); - - it('put overwrites existing DEK (upsert)', async () => { - const store = new CosmosDekStore(createMockContainer()); - await store.put({ - dekId: 'dek_up', - wrappedKey: 'old', - mekVersion: 'v1', - createdAt: '2026-01-01T00:00:00Z', - }); - await store.put({ - dekId: 'dek_up', - wrappedKey: 'new', - mekVersion: 'v2', - createdAt: '2026-01-01T00:00:00Z', - }); - const got = await store.get('dek_up'); - expect(got?.wrappedKey).toBe('new'); - expect(got?.mekVersion).toBe('v2'); - }); -}); diff --git a/vendor/bytelyst/field-encrypt/src/index.ts b/vendor/bytelyst/field-encrypt/src/index.ts deleted file mode 100644 index 7ea2e0d..0000000 --- a/vendor/bytelyst/field-encrypt/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @bytelyst/field-encrypt - * - * Application-layer field encryption for ByteLyst ecosystem. - * AES-256-GCM with envelope encryption (MEK → DEK). - * - * @example - * ```typescript - * import { createFieldEncryptor } from '@bytelyst/field-encrypt'; - * - * const encryptor = createFieldEncryptor({ - * keyProvider: 'memory', // 'akv' | 'env' | 'memory' - * }); - * - * const encrypted = await encryptor.encrypt('sensitive data', { - * userId: 'user_123', - * context: 'transcripts', - * }); - * - * const plaintext = await encryptor.decrypt(encrypted, { - * userId: 'user_123', - * context: 'transcripts', - * }); - * ``` - */ - -// ── Main API ──────────────────────────────────────── -export { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor } from './field-encryptor.js'; - -// ── Type guards ───────────────────────────────────── -export { isEncryptedField } from './guards.js'; - -// ── Types ─────────────────────────────────────────── -export type { - EncryptedField, - WrappedDek, - FieldEncryptContext, - FieldEncryptorConfig, - KeyProvider, - KeyProviderType, - DekStore, -} from './types.js'; - -// ── Low-level (for custom integrations) ───────────── -export { encryptField, decryptField, generateAesKey } from './aes-gcm.js'; -export { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; -export { DekCache } from './key-cache.js'; -export { MemoryDekStore } from './dek-store-memory.js'; -export { CosmosDekStore } from './dek-store-cosmos.js'; - -// ── Key providers (for direct use / testing) ──────── -export { MemoryKeyProvider } from './key-provider-memory.js'; -export { EnvKeyProvider } from './key-provider-env.js'; -export { AkvKeyProvider } from './key-provider-akv.js'; - -// ── Migration ─────────────────────────────────────── -export { migrateDocuments } from './migration.js'; -export type { MigrationResult, MigrateDocumentsOptions } from './migration.js'; diff --git a/vendor/bytelyst/field-encrypt/src/key-cache.ts b/vendor/bytelyst/field-encrypt/src/key-cache.ts deleted file mode 100644 index 8668252..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-cache.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @bytelyst/field-encrypt — DEK cache - * - * In-memory LRU cache with TTL for unwrapped DEKs. - * Avoids repeated AKV round-trips on every encrypt/decrypt. - */ - -interface CacheEntry { - key: Buffer; - expiresAt: number; -} - -export class DekCache { - private readonly cache = new Map(); - private readonly ttlMs: number; - private readonly maxSize: number; - - constructor(ttlMs: number = 15 * 60 * 1000, maxSize: number = 1000) { - this.ttlMs = ttlMs; - this.maxSize = maxSize; - } - - /** Get an unwrapped DEK from cache. Returns null on miss or expiry. */ - get(dekId: string): Buffer | null { - const entry = this.cache.get(dekId); - if (!entry) return null; - - if (Date.now() > entry.expiresAt) { - this.cache.delete(dekId); - return null; - } - - // Move to end (LRU refresh) - this.cache.delete(dekId); - this.cache.set(dekId, entry); - return entry.key; - } - - /** Store an unwrapped DEK in cache. */ - set(dekId: string, key: Buffer): void { - // Evict oldest if at max size - if (this.cache.size >= this.maxSize && !this.cache.has(dekId)) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey !== undefined) { - this.cache.delete(oldestKey); - } - } - - this.cache.set(dekId, { - key, - expiresAt: Date.now() + this.ttlMs, - }); - } - - /** Invalidate a specific DEK (e.g., after rotation). */ - invalidate(dekId: string): void { - this.cache.delete(dekId); - } - - /** Clear all cached DEKs. */ - clear(): void { - this.cache.clear(); - } - - /** Current cache size. */ - get size(): number { - return this.cache.size; - } - - /** Cache hit rate stats. */ - private _hits = 0; - private _misses = 0; - - /** Record a cache hit (called internally). */ - recordHit(): void { - this._hits++; - } - /** Record a cache miss (called internally). */ - recordMiss(): void { - this._misses++; - } - - /** Get hit rate as a percentage (0-100). */ - get hitRate(): number { - const total = this._hits + this._misses; - return total === 0 ? 0 : Math.round((this._hits / total) * 100); - } - - /** Reset stats counters. */ - resetStats(): void { - this._hits = 0; - this._misses = 0; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts b/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts deleted file mode 100644 index e2a9ff5..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-akv.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @bytelyst/field-encrypt — Azure Key Vault key provider - * - * Production provider — uses AKV RSA keys for DEK wrapping. - * Requires @azure/keyvault-keys and @azure/identity as peer deps. - */ - -import type { KeyProvider } from './types.js'; - -/** - * Azure Key Vault key provider. - * - * Uses RSA-OAEP wrapping — the MEK never leaves AKV. - * Requires: - * - @azure/keyvault-keys (CryptographyClient) - * - @azure/identity (DefaultAzureCredential) - * - AKV RBAC: Key Vault Crypto User role on the managed identity - */ -export class AkvKeyProvider implements KeyProvider { - private readonly vaultUrl: string; - private readonly mekName: string; - private cryptoClient: unknown | null = null; - - constructor(vaultUrl: string, mekName: string) { - if (!vaultUrl) throw new Error('AkvKeyProvider: vaultUrl is required'); - if (!mekName) throw new Error('AkvKeyProvider: mekName is required'); - this.vaultUrl = vaultUrl; - this.mekName = mekName; - } - - private async getClient(): Promise<{ - wrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; - unwrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>; - }> { - if (this.cryptoClient) return this.cryptoClient as never; - - // Dynamic import to keep peer deps optional - const { KeyClient, CryptographyClient } = await import('@azure/keyvault-keys'); - const { DefaultAzureCredential } = await import('@azure/identity'); - - const credential = new DefaultAzureCredential(); - const keyClient = new KeyClient(this.vaultUrl, credential); - const key = await keyClient.getKey(this.mekName); - - if (!key.id) { - throw new Error(`AkvKeyProvider: MEK '${this.mekName}' not found in ${this.vaultUrl}`); - } - - this.cryptoClient = new CryptographyClient(key.id, credential); - return this.cryptoClient as never; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const client = await this.getClient(); - const result = await client.wrapKey('RSA-OAEP-256', new Uint8Array(dek)); - return { - wrappedKey: Buffer.from(result.result).toString('hex'), - mekVersion: this.mekName, - }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const client = await this.getClient(); - const wrappedBytes = new Uint8Array(Buffer.from(wrappedKeyHex, 'hex')); - const result = await client.unwrapKey('RSA-OAEP-256', wrappedBytes); - return Buffer.from(result.result); - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-env.ts b/vendor/bytelyst/field-encrypt/src/key-provider-env.ts deleted file mode 100644 index 4b7de08..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-env.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @bytelyst/field-encrypt — Environment variable key provider - * - * For dev/staging — uses a hex-encoded symmetric key from an env var. - * Matches the existing MFA pattern (AUTH_TOTP_ENCRYPTION_KEY). - * - * Wrapping uses AES-256-GCM with the env key as MEK. - */ - -import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; -import type { KeyProvider } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; - -export class EnvKeyProvider implements KeyProvider { - private readonly mek: Buffer; - private readonly version: string; - - /** - * @param keyHex - Hex-encoded 32-byte key (64 hex chars). - * If shorter, it will be SHA-256 hashed to derive a 32-byte key. - */ - constructor(keyHex: string) { - if (!keyHex || keyHex.length === 0) { - throw new Error('EnvKeyProvider: encryption key must not be empty'); - } - - if (keyHex.length === 64) { - this.mek = Buffer.from(keyHex, 'hex'); - } else { - // Hash to 32 bytes — same approach as existing MFA encryption - this.mek = createHash('sha256').update(keyHex).digest(); - } - - this.version = 'env-v1'; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, this.mek, iv); - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - const wrapped = Buffer.concat([iv, tag, encrypted]); - return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const wrapped = Buffer.from(wrappedKeyHex, 'hex'); - const iv = wrapped.subarray(0, IV_BYTES); - const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); - const ciphertext = wrapped.subarray(IV_BYTES + 16); - - const decipher = createDecipheriv(ALGORITHM, this.mek, iv); - decipher.setAuthTag(tag); - let decrypted = decipher.update(ciphertext); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts b/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts deleted file mode 100644 index e586798..0000000 --- a/vendor/bytelyst/field-encrypt/src/key-provider-memory.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @bytelyst/field-encrypt — In-memory key provider - * - * For unit tests — no external dependencies. - * Generates a random MEK on instantiation. Wrapping is just XOR for simplicity in tests, - * but uses AES-256-GCM to match production semantics. - */ - -import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; -import type { KeyProvider } from './types.js'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_BYTES = 12; - -export class MemoryKeyProvider implements KeyProvider { - private readonly mek: Buffer; - private readonly version: string; - - constructor(mek?: Buffer, version?: string) { - this.mek = mek ?? randomBytes(32); - this.version = version ?? 'memory-v1'; - } - - async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> { - const iv = randomBytes(IV_BYTES); - const cipher = createCipheriv(ALGORITHM, this.mek, iv); - let encrypted = cipher.update(dek); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); - - // Format: iv (12) + tag (16) + ciphertext - const wrapped = Buffer.concat([iv, tag, encrypted]); - return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version }; - } - - async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise { - const wrapped = Buffer.from(wrappedKeyHex, 'hex'); - const iv = wrapped.subarray(0, IV_BYTES); - const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16); - const ciphertext = wrapped.subarray(IV_BYTES + 16); - - const decipher = createDecipheriv(ALGORITHM, this.mek, iv); - decipher.setAuthTag(tag); - let decrypted = decipher.update(ciphertext); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted; - } -} diff --git a/vendor/bytelyst/field-encrypt/src/migration.ts b/vendor/bytelyst/field-encrypt/src/migration.ts deleted file mode 100644 index 978c4ac..0000000 --- a/vendor/bytelyst/field-encrypt/src/migration.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @bytelyst/field-encrypt — Migration helpers - * - * Utilities for encrypting existing plaintext fields in-place. - * Idempotent — skips already-encrypted fields via __encrypted sentinel. - */ - -import type { EncryptedField } from './types.js'; -import { isEncryptedField } from './guards.js'; - -/** Result of a migration run. */ -export interface MigrationResult { - /** Total documents scanned. */ - scanned: number; - /** Documents encrypted in this run. */ - encrypted: number; - /** Documents skipped (already encrypted). */ - skipped: number; - /** Documents that failed to encrypt. */ - errors: number; - /** Error details (first 10). */ - errorDetails: Array<{ id: string; error: string }>; -} - -/** Options for migrateDocuments(). */ -export interface MigrateDocumentsOptions { - /** Fetch a batch of documents. Return empty array when done. */ - fetchBatch: (offset: number, batchSize: number) => Promise; - /** Get the document ID for logging. */ - getId: (doc: T) => string; - /** Get the field value to check/encrypt. */ - getField: (doc: T) => unknown; - /** Encrypt the plaintext value. Returns the EncryptedField. */ - encryptValue: (plaintext: string, doc: T) => Promise; - /** Write the encrypted value back to the store. */ - writeBack: (doc: T, encrypted: EncryptedField) => Promise; - /** Batch size (default: 100). */ - batchSize?: number; - /** If true, don't write — just count. */ - dryRun?: boolean; - /** Progress callback. */ - onProgress?: (result: MigrationResult) => void; -} - -/** - * Migrate plaintext fields to encrypted fields in batches. - * - * Idempotent: skips documents where the field is already an EncryptedField. - */ -export async function migrateDocuments( - options: MigrateDocumentsOptions -): Promise { - const batchSize = options.batchSize ?? 100; - const result: MigrationResult = { - scanned: 0, - encrypted: 0, - skipped: 0, - errors: 0, - errorDetails: [], - }; - - let offset = 0; - let batch: T[]; - - do { - batch = await options.fetchBatch(offset, batchSize); - - for (const doc of batch) { - result.scanned++; - const fieldValue = options.getField(doc); - - // Skip already-encrypted - if (isEncryptedField(fieldValue)) { - result.skipped++; - continue; - } - - // Skip null/undefined - if (fieldValue == null) { - result.skipped++; - continue; - } - - const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue); - - try { - const encrypted = await options.encryptValue(plaintext, doc); - - if (!options.dryRun) { - await options.writeBack(doc, encrypted); - } - - result.encrypted++; - } catch (err) { - result.errors++; - if (result.errorDetails.length < 10) { - result.errorDetails.push({ - id: options.getId(doc), - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - offset += batch.length; - options.onProgress?.(result); - } while (batch.length === batchSize); - - return result; -} diff --git a/vendor/bytelyst/field-encrypt/src/types.ts b/vendor/bytelyst/field-encrypt/src/types.ts deleted file mode 100644 index 2273fcd..0000000 --- a/vendor/bytelyst/field-encrypt/src/types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @bytelyst/field-encrypt — Types - * - * Core type definitions for field-level encryption. - */ - -/** Encrypted field stored in Cosmos DB or SQLite. */ -export interface EncryptedField { - /** Sentinel — always true for encrypted fields. */ - readonly __encrypted: true; - /** Schema version for future algorithm changes. */ - readonly v: 1; - /** Algorithm identifier. */ - readonly alg: 'aes-256-gcm'; - /** Ciphertext (hex-encoded). */ - readonly ct: string; - /** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */ - readonly iv: string; - /** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */ - readonly tag: string; - /** DEK identifier — identifies which key to unwrap for decryption. */ - readonly dekId: string; -} - -/** Wrapped DEK stored alongside data (e.g., in a `_encryption_keys` Cosmos container). */ -export interface WrappedDek { - /** Unique DEK identifier, e.g. `dek_user123_transcripts`. */ - readonly dekId: string; - /** Wrapped (encrypted) DEK bytes (hex-encoded). */ - readonly wrappedKey: string; - /** MEK name/version used to wrap this DEK. */ - readonly mekVersion: string; - /** ISO 8601 creation timestamp. */ - readonly createdAt: string; -} - -/** Options for encrypt/decrypt operations. */ -export interface FieldEncryptContext { - /** Scope for DEK isolation (typically userId). */ - readonly userId: string; - /** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */ - readonly context: string; -} - -/** Key provider — abstraction over key storage backends. */ -export interface KeyProvider { - /** Wrap (encrypt) a DEK with the master key. Returns hex-encoded wrapped key + mek version string. */ - wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }>; - /** Unwrap (decrypt) a wrapped DEK. Returns raw DEK buffer. */ - unwrapKey(wrappedKeyHex: string, mekVersion: string): Promise; -} - -/** Supported key provider types. */ -export type KeyProviderType = 'akv' | 'env' | 'memory'; - -/** DEK store — abstraction over DEK persistence. */ -export interface DekStore { - /** Get a wrapped DEK by its ID. Returns null if not found. */ - get(dekId: string): Promise; - /** Store a wrapped DEK. */ - put(dek: WrappedDek): Promise; - /** List all DEK IDs (for rotation). */ - listIds(): Promise; - /** Delete a DEK. */ - delete(dekId: string): Promise; -} - -/** Configuration for createFieldEncryptor(). */ -export interface FieldEncryptorConfig { - /** - * Master toggle — set to false to disable encryption entirely. - * When disabled, encrypt() returns plaintext as-is and decrypt() passes through. - * Default: true. - */ - enabled?: boolean; - /** Key provider type. */ - keyProvider: KeyProviderType; - /** Azure Key Vault URL (required for 'akv' provider). */ - keyVaultUrl?: string; - /** MEK name in AKV (required for 'akv' provider). */ - mekName?: string; - /** Hex-encoded encryption key (required for 'env' provider). */ - encryptionKey?: string; - /** DEK cache TTL in milliseconds (default: 15 minutes). */ - dekCacheTtlMs?: number; - /** DEK cache max size (default: 1000). */ - dekCacheMaxSize?: number; - /** DEK store implementation (default: in-memory). */ - dekStore?: DekStore; -} diff --git a/vendor/bytelyst/field-encrypt/tsconfig.json b/vendor/bytelyst/field-encrypt/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/field-encrypt/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/gentle-notifications/package.json b/vendor/bytelyst/gentle-notifications/package.json deleted file mode 100644 index 71dd299..0000000 --- a/vendor/bytelyst/gentle-notifications/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/gentle-notifications", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/gentle-notifications/src/client.test.ts b/vendor/bytelyst/gentle-notifications/src/client.test.ts deleted file mode 100644 index 22db4ce..0000000 --- a/vendor/bytelyst/gentle-notifications/src/client.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createGentleNotificationEngine, FORBIDDEN_PHRASES } from './client.js'; - -describe('createGentleNotificationEngine', () => { - it('should return default config', () => { - const engine = createGentleNotificationEngine(); - const config = engine.getDefaultConfig(); - expect(config.maxPerHour).toBe(3); - expect(config.tone).toBe('encouraging'); - expect(config.adaptiveFrequency).toBe(true); - expect(config.dismissCount).toBe(0); - expect(config.suppressThreshold).toBe(5); - }); - - it('should return a message for known type', () => { - const engine = createGentleNotificationEngine(); - const msg = engine.getMessage('reminder'); - expect(msg.title.length).toBeGreaterThan(0); - expect(msg.body.length).toBeGreaterThan(0); - expect(['encouraging', 'neutral', 'minimal']).toContain(msg.tone); - }); - - it('should return fallback for unknown type', () => { - const engine = createGentleNotificationEngine(); - const msg = engine.getMessage('nonexistent_type'); - expect(msg.title).toBe('Hey'); - expect(msg.body.length).toBeGreaterThan(0); - }); - - it('should suppress when dismiss threshold reached', () => { - const engine = createGentleNotificationEngine(); - const config = { ...engine.getDefaultConfig(), dismissCount: 5 }; - expect(engine.shouldSuppress(config)).toBe(true); - }); - - it('should not suppress when below threshold', () => { - const engine = createGentleNotificationEngine(); - const config = { ...engine.getDefaultConfig(), dismissCount: 2 }; - expect(engine.shouldSuppress(config)).toBe(false); - }); - - it('should record dismissal and reduce frequency', () => { - const engine = createGentleNotificationEngine(); - let config = engine.getDefaultConfig(); - expect(config.dismissCount).toBe(0); - - config = engine.recordDismissal(config); - expect(config.dismissCount).toBe(1); - - // Record enough to trigger suppression - for (let i = 0; i < 4; i++) { - config = engine.recordDismissal(config); - } - expect(config.dismissCount).toBe(5); - expect(config.maxPerHour).toBeLessThan(3); - }); - - it('should reset dismissals', () => { - const engine = createGentleNotificationEngine(); - let config = engine.getDefaultConfig(); - config = engine.recordDismissal(config); - config = engine.recordDismissal(config); - expect(config.dismissCount).toBe(2); - - config = engine.resetDismissals(config); - expect(config.dismissCount).toBe(0); - }); - - it('should allow registering custom messages', () => { - const engine = createGentleNotificationEngine(); - engine.registerMessages('fasting', [ - { title: 'Fasting Reminder', body: 'Your body is doing great things!', tone: 'encouraging' }, - ]); - const msg = engine.getMessage('fasting'); - expect(msg.title).toBe('Fasting Reminder'); - }); - - it('should export FORBIDDEN_PHRASES', () => { - expect(FORBIDDEN_PHRASES).toContain("You haven't"); - expect(FORBIDDEN_PHRASES).toContain('You failed'); - expect(FORBIDDEN_PHRASES.length).toBeGreaterThanOrEqual(8); - }); - - it('should detect forbidden phrases', () => { - const engine = createGentleNotificationEngine(); - expect(engine.containsForbiddenPhrase("You haven't done this yet")).toBe(true); - expect(engine.containsForbiddenPhrase('Great job today!')).toBe(false); - expect(engine.containsForbiddenPhrase('you failed the test')).toBe(true); - }); - - it('should respect custom initial config', () => { - const engine = createGentleNotificationEngine({ maxPerHour: 10, tone: 'minimal' }); - const config = engine.getDefaultConfig(); - expect(config.maxPerHour).toBe(10); - expect(config.tone).toBe('minimal'); - }); -}); diff --git a/vendor/bytelyst/gentle-notifications/src/client.ts b/vendor/bytelyst/gentle-notifications/src/client.ts deleted file mode 100644 index c4444da..0000000 --- a/vendor/bytelyst/gentle-notifications/src/client.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Neurodivergent-friendly notification messaging system. - * - * Encouraging tone, adaptive frequency, forbidden phrases. - * Pure client-side TS — no backend dependency. - */ - -import type { GentleMessage, GentleNotificationConfig, GentleNotificationEngine } from './types.js'; - -export const FORBIDDEN_PHRASES: readonly string[] = [ - "You haven't", - 'You forgot', - "Don't forget", - 'You should have', - "Why didn't you", - 'You missed', - 'You failed', - 'You need to', -] as const; - -const DEFAULT_MESSAGES: Record = { - reminder: [ - { - title: 'Gentle Reminder', - body: 'Whenever you are ready, there is something waiting for you.', - tone: 'encouraging', - }, - { - title: 'Quick Note', - body: 'No rush — just a friendly nudge when the time feels right.', - tone: 'encouraging', - }, - { - title: 'Hey there', - body: 'Take your time. We will be here when you are ready.', - tone: 'neutral', - }, - ], - progress: [ - { - title: 'Nice Progress', - body: 'Look at what you have accomplished — every step matters!', - tone: 'encouraging', - }, - { - title: 'Moving Forward', - body: 'You are making progress at your own pace. That is perfect.', - tone: 'encouraging', - }, - ], - check_in: [ - { - title: 'Check In', - body: 'How are you feeling? Remember, there is no wrong answer.', - tone: 'encouraging', - }, - { title: 'Quick Check', body: 'Just checking in — hope you are doing well!', tone: 'neutral' }, - ], - streak: [ - { - title: 'Streak Update', - body: 'Your consistency is impressive — keep it going if it feels right!', - tone: 'encouraging', - }, - ], - idle: [ - { - title: 'Welcome Back', - body: 'Great to see you again — no judgment, just glad you are here!', - tone: 'encouraging', - }, - { - title: 'Hi Again', - body: 'Whenever you are ready to jump back in, we are here.', - tone: 'neutral', - }, - ], -}; - -export function createGentleNotificationEngine( - initialConfig?: Partial -): GentleNotificationEngine { - const messagePools: Record = { ...DEFAULT_MESSAGES }; - - function getDefaultConfig(): GentleNotificationConfig { - return { - maxPerHour: 3, - tone: 'encouraging', - adaptiveFrequency: true, - dismissCount: 0, - suppressThreshold: 5, - ...initialConfig, - }; - } - - function getMessage(type: string, config?: GentleNotificationConfig): GentleMessage { - const tone = config?.tone ?? 'encouraging'; - const pool = messagePools[type]; - - if (!pool || pool.length === 0) { - return { - title: 'Hey', - body: 'Hope you are having a good day!', - tone, - }; - } - - // Filter by tone if possible, fallback to any - const toneFiltered = pool.filter(m => m.tone === tone); - const candidates = toneFiltered.length > 0 ? toneFiltered : pool; - const index = Math.floor(Math.random() * candidates.length); - return candidates[index]; - } - - function shouldSuppress(config: GentleNotificationConfig): boolean { - if (config.adaptiveFrequency && config.dismissCount >= config.suppressThreshold) { - return true; - } - return false; - } - - function recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig { - const newConfig = { ...config, dismissCount: config.dismissCount + 1 }; - if (newConfig.adaptiveFrequency && newConfig.dismissCount >= newConfig.suppressThreshold) { - newConfig.maxPerHour = Math.max(1, Math.floor(newConfig.maxPerHour / 2)); - } - return newConfig; - } - - function resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig { - return { ...config, dismissCount: 0 }; - } - - function registerMessages(type: string, messages: GentleMessage[]): void { - messagePools[type] = [...(messagePools[type] ?? []), ...messages]; - } - - function getForbiddenPhrases(): readonly string[] { - return FORBIDDEN_PHRASES; - } - - function containsForbiddenPhrase(text: string): boolean { - const lower = text.toLowerCase(); - return FORBIDDEN_PHRASES.some(phrase => lower.includes(phrase.toLowerCase())); - } - - return { - getDefaultConfig, - getMessage, - shouldSuppress, - recordDismissal, - resetDismissals, - registerMessages, - getForbiddenPhrases, - containsForbiddenPhrase, - }; -} diff --git a/vendor/bytelyst/gentle-notifications/src/index.ts b/vendor/bytelyst/gentle-notifications/src/index.ts deleted file mode 100644 index 2be8615..0000000 --- a/vendor/bytelyst/gentle-notifications/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface GentleConfig { - maxPerDay: number; - quietHoursStart: number; - quietHoursEnd: number; - minIntervalMinutes: number; - dismissCount?: number; -} - -const FORBIDDEN_PHRASES = [ - "you failed", - "you broke", - "you gave up", - "disappointed", - "shame", - "guilt", - "lazy", - "weak", - "cheat", -] as const; - -export function createGentleNotificationEngine() { - return { - containsForbiddenPhrase(text: string): boolean { - const lower = text.toLowerCase(); - return FORBIDDEN_PHRASES.some((phrase) => lower.includes(phrase)); - }, - - getDefaultConfig(): GentleConfig { - return { - maxPerDay: 8, - quietHoursStart: 22, - quietHoursEnd: 7, - minIntervalMinutes: 30, - }; - }, - - recordDismissal(config: GentleConfig): GentleConfig { - return { - ...config, - dismissCount: (config.dismissCount ?? 0) + 1, - }; - }, - }; -} diff --git a/vendor/bytelyst/gentle-notifications/src/types.ts b/vendor/bytelyst/gentle-notifications/src/types.ts deleted file mode 100644 index 7cdc031..0000000 --- a/vendor/bytelyst/gentle-notifications/src/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Types for @bytelyst/gentle-notifications. - * Pure client-side TS — no backend dependency. - */ - -export interface GentleNotificationConfig { - maxPerHour: number; - tone: 'encouraging' | 'neutral' | 'minimal'; - adaptiveFrequency: boolean; - dismissCount: number; - suppressThreshold: number; -} - -export interface GentleMessage { - title: string; - body: string; - tone: 'encouraging' | 'neutral' | 'minimal'; -} - -export interface GentleNotificationEngine { - getDefaultConfig(): GentleNotificationConfig; - getMessage(type: string, config?: GentleNotificationConfig): GentleMessage; - shouldSuppress(config: GentleNotificationConfig): boolean; - recordDismissal(config: GentleNotificationConfig): GentleNotificationConfig; - resetDismissals(config: GentleNotificationConfig): GentleNotificationConfig; - registerMessages(type: string, messages: GentleMessage[]): void; - getForbiddenPhrases(): readonly string[]; - containsForbiddenPhrase(text: string): boolean; -} diff --git a/vendor/bytelyst/gentle-notifications/tsconfig.json b/vendor/bytelyst/gentle-notifications/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/gentle-notifications/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/kill-switch-client/package.json b/vendor/bytelyst/kill-switch-client/package.json deleted file mode 100644 index 5e2458c..0000000 --- a/vendor/bytelyst/kill-switch-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/kill-switch-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe kill switch client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/kill-switch-client/src/index.test.ts b/vendor/bytelyst/kill-switch-client/src/index.test.ts deleted file mode 100644 index 7ac17a3..0000000 --- a/vendor/bytelyst/kill-switch-client/src/index.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createKillSwitchClient } from './index.js'; - -describe('createKillSwitchClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - }; - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should return disabled=false when app is not disabled', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false, message: null }), - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - expect(result.message).toBeNull(); - }); - - it('should return disabled=true with message when app is disabled', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }), - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(true); - expect(result.message).toBe('Maintenance in progress'); - }); - - it('should fail-open on network error', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - expect(result.message).toBeNull(); - }); - - it('should fail-open on non-OK response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const ks = createKillSwitchClient(baseConfig); - const result = await ks.check(); - - expect(result.disabled).toBe(false); - }); - - it('should send correct product-id header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false }), - }); - vi.stubGlobal('fetch', fetchMock); - - const ks = createKillSwitchClient(baseConfig); - await ks.check(); - - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/flags/kill-switch'), - expect.objectContaining({ - headers: expect.objectContaining({ 'x-product-id': 'testapp' }), - }) - ); - }); - - it('should include platform in query string', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ disabled: false }), - }); - vi.stubGlobal('fetch', fetchMock); - - const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' }); - await ks.check(); - - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('platform=ios'); - }); -}); diff --git a/vendor/bytelyst/kill-switch-client/src/index.ts b/vendor/bytelyst/kill-switch-client/src/index.ts deleted file mode 100644 index e41339d..0000000 --- a/vendor/bytelyst/kill-switch-client/src/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Browser/React Native-safe kill switch client for platform-service. - * - * Checks GET /api/flags/kill-switch to determine if the app is disabled. - * Fail-open: returns { disabled: false } on any network error. - * - * @example - * ```ts - * import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; - * - * const ks = createKillSwitchClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * }); - * - * const result = await ks.check(); - * if (result.disabled) showBlockScreen(result.message); - * ``` - */ - -export interface KillSwitchClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Platform string for the query (e.g. "mobile", "web"). Default: "mobile". */ - platform?: string; -} - -export interface KillSwitchResult { - disabled: boolean; - message: string | null; -} - -export interface KillSwitchClient { - /** Check if the app is disabled. Fail-open on any error. */ - check(): Promise; -} - -export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient { - const { baseUrl, productId, platform = 'mobile' } = config; - - async function check(): Promise { - try { - const requestId = - typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - const res = await globalThis.fetch( - `${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`, - { - headers: { 'x-product-id': productId, 'x-request-id': requestId }, - } - ); - - if (!res.ok) return { disabled: false, message: null }; - - const data = (await res.json()) as KillSwitchResult; - return { - disabled: data.disabled ?? false, - message: data.message ?? null, - }; - } catch { - // Fail-open: network errors should NOT block the user - return { disabled: false, message: null }; - } - } - - return { check }; -} diff --git a/vendor/bytelyst/kill-switch-client/tsconfig.json b/vendor/bytelyst/kill-switch-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/kill-switch-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/.gitignore b/vendor/bytelyst/kotlin-platform-sdk/.gitignore deleted file mode 100644 index 4705270..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Gradle -.gradle/ -build/ -local.properties - -# IDE -.idea/ -*.iml - -# OS -.DS_Store diff --git a/vendor/bytelyst/kotlin-platform-sdk/README.md b/vendor/bytelyst/kotlin-platform-sdk/README.md deleted file mode 100644 index 78688e1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/README.md +++ /dev/null @@ -1,574 +0,0 @@ -# ByteLyst Platform SDK — Android (Kotlin) - -Kotlin SDK for the ByteLyst platform. Provides broadcast messaging, surveys, authentication, telemetry, and more. - -## Installation - -### Gradle - -Add to your `build.gradle.kts`: - -```kotlin -dependencies { - implementation("com.bytelyst:platform-sdk:1.0.0") -} -``` - -### Maven - -```xml - - com.bytelyst - platform-sdk - 1.0.0 - -``` - -## Quick Start - -```kotlin -import com.bytelyst.platform.* - -// Configure the SDK -val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = "https://api.bytelyst.io/v1", - getAuthToken = { authRepository.getToken() } -) - -// Create clients -val broadcastClient = BLBroadcastClient(config) -val surveyClient = BLSurveyClient(config) -``` - -## Broadcast Client - -### Basic Usage - -```kotlin -import com.bytelyst.platform.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class BroadcastManager( - private val client: BLBroadcastClient -) { - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _messages - - private val _unreadCount = MutableStateFlow(0) - val unreadCount: StateFlow = _unreadCount - - fun startListening() { - client.startPolling(60000L) { messages -> - _messages.value = messages - _unreadCount.value = messages.count { it.status == MessageStatus.UNREAD } - } - } - - fun stopListening() { - client.stopPolling() - } - - suspend fun markRead(messageId: String) { - client.markRead(messageId) - } - - suspend fun dismiss(messageId: String) { - client.markDismissed(messageId) - } - - suspend fun handleTap(message: InAppMessage) { - client.trackClick(message.id) - - message.ctaUrl?.let { url -> - // Open URL with your navigation system - navigationService.openUrl(url) - } - - markRead(message.id) - } -} -``` - -### Jetpack Compose Integration - -```kotlin -import com.bytelyst.platform.ui.* - -@Composable -fun AppContent() { - val broadcastManager = remember { BroadcastManager(broadcastClient) } - val messages by broadcastManager.messages.collectAsState() - val unreadCount by broadcastManager.unreadCount.collectAsState() - - LaunchedEffect(Unit) { - broadcastManager.startListening() - } - - Scaffold( - topBar = { - // Banner for unread messages - InAppMessageBanner( - client = broadcastClient, - position = BannerPosition.TOP - ) - } - ) { padding -> - MainContent(modifier = Modifier.padding(padding)) - } -} -``` - -### Modal Messages - -```kotlin -@Composable -fun AppRoot() { - val broadcastClient = remember { BLBroadcastClient(config) } - - Box(modifier = Modifier.fillMaxSize()) { - NavigationHost() - - // Modal overlay - BroadcastModal(client = broadcastClient) - } -} -``` - -## Survey Client - -### Basic Usage - -```kotlin -import com.bytelyst.platform.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -class SurveyManager( - private val client: BLSurveyClient -) { - private val _activeSurvey = MutableStateFlow(null) - val activeSurvey: StateFlow = _activeSurvey - - private val _currentQuestionIndex = MutableStateFlow(0) - val currentQuestionIndex: StateFlow = _currentQuestionIndex - - private val _answers = MutableStateFlow>(emptyMap()) - val answers: StateFlow> = _answers - - private val _isComplete = MutableStateFlow(false) - val isComplete: StateFlow = _isComplete - - suspend fun checkForSurveys() { - val result = client.getActiveSurvey() - result.onSuccess { survey -> - survey?.let { - _activeSurvey.value = it - client.startSurvey(it.id) - } - } - } - - suspend fun submitAnswer(question: Question, answer: SurveyAnswer) { - val survey = _activeSurvey.value ?: return - - val result = client.submitAnswer( - surveyId = survey.id, - questionId = question.id, - answer = answer - ) - - result.onSuccess { response -> - _currentQuestionIndex.value = response.currentQuestionIndex - _answers.value = _answers.value + (question.id to answer) - - if (response.isComplete) { - completeSurvey() - } - } - } - - suspend fun completeSurvey() { - val survey = _activeSurvey.value ?: return - - val result = client.completeSurvey(survey.id) - result.onSuccess { completion -> - if (completion.success) { - _isComplete.value = true - - if (completion.incentiveClaimed) { - showIncentiveToast( - amount = completion.incentiveAmount, - type = completion.incentiveType - ) - } - } - } - } - - suspend fun dismiss() { - val survey = _activeSurvey.value ?: return - client.dismissSurvey(survey.id) - - _activeSurvey.value = null - _currentQuestionIndex.value = 0 - _answers.value = emptyMap() - } -} -``` - -### Jetpack Compose Survey Modal - -```kotlin -@Composable -fun AppRoot() { - val surveyClient = remember { BLSurveyClient(config) } - - Box(modifier = Modifier.fillMaxSize()) { - NavigationHost() - - // Survey modal overlay - SurveyModal(client = surveyClient) - } -} -``` - -### Custom Survey UI - -```kotlin -@Composable -fun CustomSurveyView( - manager: SurveyManager = viewModel() -) { - val survey by manager.activeSurvey.collectAsState() - val currentIndex by manager.currentQuestionIndex.collectAsState() - val isComplete by manager.isComplete.collectAsState() - - if (survey != null && !isComplete) { - val question = survey!!.questions[currentIndex] - val isLast = currentIndex == survey!!.questions.size - 1 - - Column(modifier = Modifier.padding(16.dp)) { - // Progress - LinearProgressIndicator( - progress = (currentIndex + 1) / survey!!.questions.size.toFloat(), - modifier = Modifier.fillMaxWidth() - ) - - Text( - text = "Question ${currentIndex + 1} of ${survey!!.questions.size}", - style = MaterialTheme.typography.labelSmall - ) - - // Question - Text( - text = question.text, - style = MaterialTheme.typography.titleLarge - ) - - question.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Answer input based on type - QuestionInput( - question = question, - onAnswer = { answer -> - coroutineScope.launch { - manager.submitAnswer(question, answer) - } - } - ) - - // Navigation - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (!question.required) { - TextButton(onClick = { /* Skip */ }) { - Text("Skip") - } - } - - Button( - onClick = { /* Submit */ }, - enabled = canSubmit(question) - ) { - Text(if (isLast) "Complete" else "Next") - } - } - } - } -} - -@Composable -fun QuestionInput( - question: Question, - onAnswer: (SurveyAnswer) -> Unit -) { - when (question.type) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - var selected by remember { mutableStateOf(null) } - - Column { - question.options?.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - selected = option.id - onAnswer(SurveyAnswer( - type = "single_choice", - value = JsonObject(mapOf("value" to JsonPrimitive(option.id))) - )) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text(text = option.text, modifier = Modifier.weight(1f)) - RadioButton( - selected = selected == option.id, - onClick = null - ) - } - } - } - } - - QuestionType.MULTIPLE_CHOICE -> { - var selected by remember { mutableStateOf>(emptySet()) } - - Column { - question.options?.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - selected = if (selected.contains(option.id)) { - selected - option.id - } else { - selected + option.id - } - onAnswer(SurveyAnswer( - type = "multiple_choice", - value = JsonObject(mapOf("values" to JsonArray(selected.map { JsonPrimitive(it) }))) - )) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text(text = option.text, modifier = Modifier.weight(1f)) - Checkbox( - checked = selected.contains(option.id), - onCheckedChange = null - ) - } - } - } - } - - QuestionType.NPS, QuestionType.RATING, QuestionType.SCALE -> { - var rating by remember { mutableIntStateOf(0) } - val minValue = question.minValue ?: if (question.type == QuestionType.NPS) 0 else 1 - val maxValue = question.maxValue ?: if (question.type == QuestionType.NPS) 10 else 5 - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - (minValue..maxValue).forEach { value -> - Button( - onClick = { - rating = value - onAnswer(SurveyAnswer( - type = "rating", - value = JsonObject(mapOf("value" to JsonPrimitive(value))) - )) - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (rating == value) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Text("$value") - } - } - } - } - - QuestionType.TEXT_SHORT -> { - var text by remember { mutableStateOf("") } - OutlinedTextField( - value = text, - onValueChange = { - text = it - onAnswer(SurveyAnswer( - type = "text", - value = JsonObject(mapOf("value" to JsonPrimitive(it))) - )) - }, - modifier = Modifier.fillMaxWidth() - ) - } - - QuestionType.TEXT_LONG -> { - var text by remember { mutableStateOf("") } - OutlinedTextField( - value = text, - onValueChange = { - text = it - onAnswer(SurveyAnswer( - type = "text", - value = JsonObject(mapOf("value" to JsonPrimitive(it))) - )) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - maxLines = 6 - ) - } - - else -> { /* Handle other types */ } - } -} -``` - -## Push Notifications - -### Firebase Cloud Messaging - -```kotlin -import com.google.firebase.messaging.FirebaseMessagingService -import com.google.firebase.messaging.RemoteMessage -import com.bytelyst.platform.* - -class PushNotificationService : FirebaseMessagingService() { - private lateinit var broadcastClient: BLBroadcastClient - - override fun onCreate() { - super.onCreate() - val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = "https://api.bytelyst.io/v1", - getAuthToken = { authRepository.getToken() } - ) - broadcastClient = BLBroadcastClient(config) - } - - override fun onNewToken(token: String) { - super.onNewToken(token) - - // Register device token - coroutineScope.launch { - broadcastClient.registerDeviceToken(token, Platform.ANDROID) - } - } - - override fun onMessageReceived(message: RemoteMessage) { - super.onMessageReceived(message) - - // Handle broadcast notification - message.data["broadcastId"]?.let { broadcastId -> - showNotification(message) - } - } -} -``` - -### Register Device Token - -```kotlin -FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (task.isSuccessful) { - val token = task.result - coroutineScope.launch { - broadcastClient.registerDeviceToken(token, Platform.ANDROID) - } - } -} -``` - -## Error Handling - -```kotlin -val result = client.getActiveSurvey() - -result.onSuccess { survey -> - survey?.let { showSurvey(it) } -}.onError { error -> - when (error) { - is BLApiError.Unauthorized -> { - // Re-authenticate user - authRepository.refreshToken() - } - is BLApiError.NetworkError -> { - // Retry with exponential backoff - Log.w("Survey", "Network error, will retry") - } - is BLApiError.ServerError -> { - Log.e("Survey", "Server error: ${error.code}") - } - else -> { - Log.e("Survey", "Unknown error: ${error.message}") - } - } -} -``` - -## Configuration - -### Environment-Based Config - -```kotlin -sealed class Environment( - val baseURL: String -) { - object Development : Environment("http://localhost:4003") - object Staging : Environment("https://api-staging.bytelyst.io/v1") - object Production : Environment("https://api.bytelyst.io/v1") -} - -val config = BLPlatformConfig( - productId = "lysnrai", - baseURL = Environment.Production.baseURL, - getAuthToken = { authRepository.getToken() }, - enableLogging = BuildConfig.DEBUG -) -``` - -## Offline Support - -The SDK automatically caches survey responses offline: - -```kotlin -val client = BLSurveyClient( - config = config, - enableOfflineCache = true // Enabled by default -) - -// Responses are queued when offline -// Flush manually or on network restore -connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - coroutineScope.launch { - client.flushOfflineQueue() - } - } -}) -``` - -## Requirements - -- Android 8.0+ (API 26+) -- Kotlin 1.9+ -- Jetpack Compose (optional, for UI components) - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts deleted file mode 100644 index e0e47a5..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/build.gradle.kts +++ /dev/null @@ -1,77 +0,0 @@ -plugins { - id("com.android.library") version "8.7.3" - id("org.jetbrains.kotlin.android") version "2.1.0" - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" - id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" -} - -android { - namespace = "com.bytelyst.platform" - compileSdk = 35 - - defaultConfig { - minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - compose = true - } -} - -group = "com.bytelyst.platform" -version = "0.1.0" - -dependencies { - // HTTP - api("com.squareup.okhttp3:okhttp:4.12.0") - - // Serialization - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - - // Compose UI (for SurveyUI, InAppMessageUI, BroadcastUI) - implementation(platform("androidx.compose:compose-bom:2024.12.01")) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.compose.foundation:foundation") - - // Image loading (for BroadcastUI AsyncImage) - implementation("io.coil-kt:coil-compose:2.7.0") - - // Android - implementation("androidx.security:security-crypto:1.0.0") - implementation("androidx.biometric:biometric:1.1.0") - implementation("androidx.core:core-ktx:1.15.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - - // Credential Manager (Passkeys) - implementation("androidx.credentials:credentials:1.5.0-beta01") - implementation("androidx.credentials:credentials-play-services-auth:1.5.0-beta01") - - // Testing - testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - testImplementation("org.robolectric:robolectric:4.14.1") -} - -tasks.withType { - useJUnitPlatform() -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro b/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro deleted file mode 100644 index a774057..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/consumer-rules.pro +++ /dev/null @@ -1,3 +0,0 @@ -# ByteLyst Platform SDK — consumer ProGuard rules -# Keep all SDK public API classes --keep class com.bytelyst.platform.** { *; } diff --git a/vendor/bytelyst/kotlin-platform-sdk/gradle.properties b/vendor/bytelyst/kotlin-platform-sdk/gradle.properties deleted file mode 100644 index 5bac8ac..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -android.useAndroidX=true diff --git a/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts b/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts deleted file mode 100644 index b4d1db6..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/settings.gradle.kts +++ /dev/null @@ -1,16 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "kotlin-platform-sdk" diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml b/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml deleted file mode 100644 index 1f26d1e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt deleted file mode 100644 index 57fb13b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone - -/** - * Local rotating JSON audit log. - * - * Writes audit entries to a JSON lines file in the app's files directory. - * Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files. - * - * Mirrors the Swift BLAuditLogger API. - */ -class BLAuditLogger( - context: Context, - private val config: BLPlatformConfig, - private val maxFileSizeBytes: Long = 1_000_000L, - private val maxFiles: Int = 5, -) { - @Serializable - data class AuditEntry( - val timestamp: String, - val productId: String, - val action: String, - val module: String, - val detail: String? = null, - val userId: String? = null, - ) - - private val json = Json { encodeDefaults = true } - private val logDir = File(context.filesDir, "audit_logs") - private val currentFile: File - get() = File(logDir, "${config.productId}_audit.jsonl") - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - init { - logDir.mkdirs() - } - - /** - * Log an audit event. - */ - fun log(action: String, module: String, detail: String? = null, userId: String? = null) { - val entry = AuditEntry( - timestamp = isoNow(), - productId = config.productId, - action = action, - module = module, - detail = detail, - userId = userId, - ) - - try { - rotateIfNeeded() - currentFile.appendText(json.encodeToString(entry) + "\n") - } catch (_: Exception) { - // Audit logging should never crash the app - } - } - - /** - * Read all entries from the current log file. - */ - fun readEntries(): List { - return try { - if (!currentFile.exists()) return emptyList() - currentFile.readLines() - .filter { it.isNotBlank() } - .mapNotNull { - try { json.decodeFromString(it) } catch (_: Exception) { null } - } - } catch (_: Exception) { - emptyList() - } - } - - /** - * Clear all audit log files. - */ - fun clear() { - try { - logDir.listFiles()?.forEach { it.delete() } - } catch (_: Exception) { - // Ignore - } - } - - private fun rotateIfNeeded() { - if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return - - // Rotate: current → .1, .1 → .2, etc. - for (i in maxFiles downTo 1) { - val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl") - val to = File(logDir, "${config.productId}_audit.$i.jsonl") - if (from.exists()) { - to.delete() - from.renameTo(to) - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt deleted file mode 100644 index 25222b1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt +++ /dev/null @@ -1,641 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer - -/** - * Auth client for platform-service. - * - * Manages login, register, token refresh, password operations. - * SmartAuth v2: social login, MFA (TOTP), device trust, step-up auth. - * Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences). - * Auth state is exposed as a [StateFlow] for reactive UI binding. - * - * Mirrors the Swift BLAuthClient API. - */ -class BLAuthClient( - private val config: BLPlatformConfig, - private val secureStore: BLSecureStore, -) { - // ── Data classes ────────────────────────────────────────── - - @Serializable - data class AuthUser( - val id: String = "", - val email: String = "", - @SerialName("displayName") - val name: String = "", - val plan: String = "free", - val role: String = "user", - ) - - @Serializable - data class TokenResponse( - val accessToken: String, - val refreshToken: String, - val user: AuthUser, - ) - - @Serializable - data class RefreshResponse( - val accessToken: String, - val refreshToken: String, - ) - - @Serializable - data class MessageResponse(val message: String) - - // ── SmartAuth v2 types ──────────────────────────────────── - - @Serializable - data class MfaChallenge( - val mfaRequired: Boolean = false, - val mfaChallenge: String = "", - val methods: List = emptyList(), - ) - - @Serializable - data class TotpSetup( - val otpauthUri: String, - val qrCode: String, - val recoveryCodes: List, - ) - - @Serializable - data class MfaStatus( - val mfaEnabled: Boolean, - val methods: List, - val recoveryCodesRemaining: Int, - ) - - @Serializable - data class AuthProvider( - val provider: String, - val email: String, - val linkedAt: String, - val lastUsedAt: String? = null, - ) - - @Serializable - data class Device( - val fingerprint: String, - val trustLevel: String, - val deviceInfo: DeviceInfo? = null, - val lastIp: String? = null, - val lastLocation: String? = null, - val trustExpiresAt: String? = null, - val createdAt: String, - val lastSeenAt: String, - val isTrusted: Boolean, - ) { - /** Convenience: display name from device info. */ - val name: String get() = deviceInfo?.model ?: deviceInfo?.platform ?: fingerprint.take(8) - /** Convenience: platform string. */ - val platform: String get() = deviceInfo?.platform ?: "unknown" - } - - @Serializable - data class DeviceInfo( - val userAgent: String? = null, - val platform: String? = null, - val model: String? = null, - val os: String? = null, - ) - - @Serializable - data class LoginEvent( - val id: String, - val eventType: String, - val method: String, - val ip: String, - val geo: Geo? = null, - val riskScore: Int, - val createdAt: String, - ) - - @Serializable - data class Geo( - val country: String, - val city: String, - ) - - @Serializable - data class RecoveryCodesResponse( - val recoveryCodes: List, - ) - - @Serializable - data class Passkey( - val id: String, - val friendlyName: String, - val deviceType: String, - val lastUsedAt: String? = null, - val createdAt: String = "", - ) - - @Serializable - data class StepUpResponse( - val stepUpToken: String, - ) - - // ── Phase 5C–5E types ───────────────────────────────────── - - @Serializable - data class TotpSecret( - val secret: String, - val issuer: String, - val accountName: String, - val digits: Int = 6, - val period: Int = 30, - val algorithm: String = "SHA1", - ) - - @Serializable - data class PushApproval( - val id: String, - val requestProductId: String, - val requestPlatform: String, - val requestIp: String, - val requestGeo: Geo? = null, - val createdAt: String, - val expiresAt: String, - ) - - @Serializable - data class PushApprovalResponse( - val id: String, - val status: String, - val respondedAt: String? = null, - ) - - @Serializable - data class QrChallenge( - val id: String, - val challengeToken: String, - val expiresAt: String, - ) - - @Serializable - data class QrStatus( - val status: String, - val accessToken: String? = null, - val refreshToken: String? = null, - val user: AuthUser? = null, - ) - - /** Exception when MFA is required after login. */ - class MfaRequiredException(val challenge: MfaChallenge) : Exception("MFA required") - - sealed class AuthState { - data object Loading : AuthState() - data object LoggedOut : AuthState() - data class LoggedIn(val user: AuthUser) : AuthState() - data class MfaRequired(val challenge: MfaChallenge) : AuthState() - data class Error(val message: String) : AuthState() - } - - // ── State ──────────────────────────────────────────────── - - private val _state = MutableStateFlow(AuthState.Loading) - val state: StateFlow = _state.asStateFlow() - - val isLoggedIn: Boolean - get() = _state.value is AuthState.LoggedIn - - val currentUser: AuthUser? - get() = (_state.value as? AuthState.LoggedIn)?.user - - // ── Storage keys (bare — applicationId provides namespace) ── - - companion object { - private const val KEY_ACCESS_TOKEN = "access_token" - private const val KEY_REFRESH_TOKEN = "refresh_token" - private const val KEY_USER_EMAIL = "user_email" - private const val KEY_USER_NAME = "user_name" - private const val KEY_USER_PLAN = "user_plan" - private const val KEY_USER_ID = "user_id" - } - - // ── Platform client ────────────────────────────────────── - - internal val client = BLPlatformClient(config) { getAccessToken() } - - // ── Token management ───────────────────────────────────── - - fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN) - - fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN) - - private fun setTokens(accessToken: String, refreshToken: String) { - secureStore.save(KEY_ACCESS_TOKEN, accessToken) - secureStore.save(KEY_REFRESH_TOKEN, refreshToken) - } - - private fun saveUser(user: AuthUser) { - secureStore.save(KEY_USER_ID, user.id) - secureStore.save(KEY_USER_EMAIL, user.email) - secureStore.save(KEY_USER_NAME, user.name) - secureStore.save(KEY_USER_PLAN, user.plan) - } - - private fun clearAll() { - secureStore.delete(KEY_ACCESS_TOKEN) - secureStore.delete(KEY_REFRESH_TOKEN) - secureStore.delete(KEY_USER_EMAIL) - secureStore.delete(KEY_USER_NAME) - secureStore.delete(KEY_USER_PLAN) - secureStore.delete(KEY_USER_ID) - } - - // ── Session check ──────────────────────────────────────── - - /** - * Check for an existing session from stored tokens. - * Call once at app startup to restore auth state. - */ - fun checkExistingSession() { - val token = secureStore.read(KEY_ACCESS_TOKEN) - val email = secureStore.read(KEY_USER_EMAIL) - if (!token.isNullOrBlank() && !email.isNullOrBlank()) { - val user = AuthUser( - id = secureStore.read(KEY_USER_ID) ?: "", - email = email, - name = secureStore.read(KEY_USER_NAME) ?: "", - plan = secureStore.read(KEY_USER_PLAN) ?: "free", - ) - _state.value = AuthState.LoggedIn(user) - } else { - _state.value = AuthState.LoggedOut - } - } - - // ── Auth operations ────────────────────────────────────── - - suspend fun login(email: String, password: String) { - _state.value = AuthState.Loading - try { - val body = encodeMap(mapOf("email" to email, "password" to password, "productId" to config.productId)) - val response = client.request("POST", "/api/auth/login", body, skipAuth = true) - // Check for MFA challenge - try { - val challenge = client.json.decodeFromString(response) - if (challenge.mfaRequired) { - _state.value = AuthState.MfaRequired(challenge) - return - } - } catch (_: Exception) { /* Not an MFA response, continue */ } - val result = client.json.decodeFromString(response) - handleAuthResult(result) - } catch (e: Exception) { - _state.value = AuthState.Error(e.message ?: "Login failed") - } - } - - suspend fun register(name: String, email: String, password: String) { - _state.value = AuthState.Loading - try { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf( - "email" to email, - "displayName" to name, - "password" to password, - "productId" to config.productId, - ), - ) - val response = client.request("POST", "/api/auth/register", body, skipAuth = true) - val result = client.json.decodeFromString(response) - handleAuthResult(result) - } catch (e: Exception) { - _state.value = AuthState.Error(e.message ?: "Registration failed") - } - } - - fun logout() { - clearAll() - _state.value = AuthState.LoggedOut - } - - // ── Token refresh (singleton guard) ────────────────────── - - private val refreshMutex = Mutex() - - suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { - val rt = getRefreshToken() ?: return false - return try { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("refreshToken" to rt), - ) - val response = client.request("POST", "/api/auth/refresh", body, skipAuth = true) - val result = client.json.decodeFromString(response) - setTokens(result.accessToken, result.refreshToken) - true - } catch (e: BLApiException) { - if (e.statusCode == 401) { - withContext(Dispatchers.Main) { logout() } - } - false - } catch (_: Exception) { - false - } - } - - // ── Password management ────────────────────────────────── - - suspend fun forgotPassword(email: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("email" to email, "productId" to config.productId), - ) - val response = client.request("POST", "/api/auth/forgot-password", body, skipAuth = true) - return client.json.decodeFromString(response).message - } - - suspend fun resetPassword(token: String, newPassword: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("token" to token, "newPassword" to newPassword), - ) - val response = client.request("POST", "/api/auth/reset-password", body, skipAuth = true) - return client.json.decodeFromString(response).message - } - - suspend fun changePassword(currentPassword: String, newPassword: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("currentPassword" to currentPassword, "newPassword" to newPassword), - ) - val response = client.request("POST", "/api/auth/change-password", body) - return client.json.decodeFromString(response).message - } - - // ── Email verification ─────────────────────────────────── - - suspend fun verifyEmail(token: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("token" to token), - ) - val response = client.request("POST", "/api/auth/verify-email", body) - return client.json.decodeFromString(response).message - } - - suspend fun resendVerification(email: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("email" to email, "productId" to config.productId), - ) - val response = client.request("POST", "/api/auth/resend-verification", body) - return client.json.decodeFromString(response).message - } - - // ── Account management ─────────────────────────────────── - - suspend fun deleteAccount(password: String): String { - val body = client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("password" to password), - ) - val response = client.request("DELETE", "/api/auth/account", body) - clearAll() - _state.value = AuthState.LoggedOut - return client.json.decodeFromString(response).message - } - - suspend fun getMe(): AuthUser { - val response = client.request("GET", "/api/auth/me") - return client.json.decodeFromString(response) - } - - // ── Social Login (SmartAuth v2) ───────────────────────── - - /** Login with Google id_token. */ - suspend fun loginWithGoogle(idToken: String): AuthUser = socialLogin("google", idToken) - - /** Login with Microsoft id_token. */ - suspend fun loginWithMicrosoft(idToken: String): AuthUser = socialLogin("microsoft", idToken) - - /** Login with Apple id_token. */ - suspend fun loginWithApple(idToken: String): AuthUser = socialLogin("apple", idToken) - - private suspend fun socialLogin(provider: String, idToken: String): AuthUser { - _state.value = AuthState.Loading - val body = encodeMap(mapOf("idToken" to idToken, "productId" to config.productId)) - val response = client.request("POST", "/api/auth/oauth/$provider", body, skipAuth = true) - // Check for MFA challenge - try { - val challenge = client.json.decodeFromString(response) - if (challenge.mfaRequired) { - _state.value = AuthState.MfaRequired(challenge) - throw MfaRequiredException(challenge) - } - } catch (e: MfaRequiredException) { - throw e - } catch (_: Exception) { /* Not an MFA response */ } - val result = client.json.decodeFromString(response) - handleAuthResult(result) - return result.user - } - - // ── MFA (SmartAuth v2) ─────────────────────────────────── - - /** Verify MFA challenge (TOTP code or recovery code). */ - suspend fun verifyMfa(challengeToken: String, code: String, method: String = "totp"): AuthUser { - val body = encodeMap(mapOf( - "challengeToken" to challengeToken, - "code" to code, - "method" to method, - )) - val response = client.request("POST", "/api/auth/mfa/verify", body, skipAuth = true) - val result = client.json.decodeFromString(response) - handleAuthResult(result) - return result.user - } - - /** Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. */ - suspend fun setupTotp(): TotpSetup { - val response = client.request("POST", "/api/auth/mfa/totp/setup") - return client.json.decodeFromString(response) - } - - /** Verify TOTP setup with a code from the authenticator app. */ - suspend fun verifyTotpSetup(code: String) { - val body = encodeMap(mapOf("code" to code)) - client.request("POST", "/api/auth/mfa/totp/verify-setup", body) - } - - /** Disable MFA (requires step-up token). */ - suspend fun disableMfa() { - client.request("DELETE", "/api/auth/mfa/totp") - } - - /** Get current MFA status. */ - suspend fun getMfaStatus(): MfaStatus { - val response = client.request("GET", "/api/auth/mfa/status") - return client.json.decodeFromString(response) - } - - /** Regenerate recovery codes (requires step-up). */ - suspend fun regenerateRecoveryCodes(): List { - val response = client.request("POST", "/api/auth/mfa/recovery/regenerate") - return client.json.decodeFromString(response).recoveryCodes - } - - // ── Providers (SmartAuth v2) ───────────────────────────── - - /** List linked OAuth providers. */ - suspend fun getProviders(): List { - val response = client.request("GET", "/api/auth/providers") - return client.json.decodeFromString>(response) - } - - /** Link an OAuth provider to the current account. */ - suspend fun linkProvider(provider: String, idToken: String) { - val body = encodeMap(mapOf("provider" to provider, "idToken" to idToken)) - client.request("POST", "/api/auth/providers/link", body) - } - - /** Unlink an OAuth provider. */ - suspend fun unlinkProvider(provider: String) { - client.request("DELETE", "/api/auth/providers/$provider") - } - - // ── Devices (SmartAuth v2) ─────────────────────────────── - - @Serializable - private data class DevicesResponse(val devices: List) - - /** List devices for current user. */ - suspend fun listDevices(): List { - val response = client.request("GET", "/api/auth/devices") - return client.json.decodeFromString(response).devices - } - - /** Trust the current device (promotes to trusted, skips MFA for 90 days). */ - suspend fun trustDevice() { - client.request("POST", "/api/auth/devices/trust") - } - - /** Revoke trust on a specific device by fingerprint. */ - suspend fun revokeDevice(fingerprint: String) { - client.request("DELETE", "/api/auth/devices/$fingerprint") - } - - /** Revoke all device trust. */ - suspend fun revokeAllDevices() { - client.request("POST", "/api/auth/devices/revoke-all") - } - - // ── Step-Up Auth (SmartAuth v2) ────────────────────────── - - /** Perform step-up authentication. Returns a short-lived step-up token. */ - suspend fun stepUp(method: String, credential: String): String { - val body = encodeMap(mapOf("method" to method, "credential" to credential)) - val response = client.request("POST", "/api/auth/step-up", body) - return client.json.decodeFromString(response).stepUpToken - } - - // ── Login History (SmartAuth v2) ───────────────────────── - - @Serializable - private data class EventsResponse(val events: List) - - /** Get login events for the current user. */ - suspend fun getLoginHistory(limit: Int = 20): List { - val response = client.request("GET", "/api/auth/login-events?limit=$limit") - return client.json.decodeFromString(response).events - } - - // ── TOTP Secret Retrieval (Phase 5C) ───────────────────── - - /** Get the decrypted TOTP secret for local code generation (auth app). */ - suspend fun getTotpSecret(): TotpSecret { - val response = client.request("GET", "/api/auth/mfa/totp/secret") - return client.json.decodeFromString(response) - } - - // ── Push Approvals (Phase 5D) ──────────────────────────── - - /** List pending push MFA approvals for the current user. */ - suspend fun getPendingApprovals(): List { - val response = client.request("GET", "/api/auth/mfa/push/pending") - return client.json.decodeFromString>(response) - } - - /** Respond to a push MFA approval (approve or deny). */ - suspend fun respondToApproval(approvalId: String, action: String): PushApprovalResponse { - val body = encodeMap(mapOf("action" to action)) - val response = client.request("POST", "/api/auth/mfa/push/$approvalId/respond", body) - return client.json.decodeFromString(response) - } - - // ── QR Auth (Phase 5E) ─────────────────────────────────── - - /** Confirm a QR login challenge from the auth app. */ - suspend fun confirmQrLogin(challengeToken: String): MessageResponse { - val body = encodeMap(mapOf("challengeToken" to challengeToken)) - val response = client.request("POST", "/api/auth/qr/confirm", body) - return client.json.decodeFromString(response) - } - - // ── Session restore ───────────────────────────────────── - - /** - * Restore session from stored tokens. Call on app launch. - * Attempts to fetch current user; falls back to token refresh. - * Mirrors Swift BLAuthClient.restoreSession(). - */ - suspend fun restoreSession() { - val token = getAccessToken() - if (token.isNullOrBlank()) { - _state.value = AuthState.LoggedOut - return - } - _state.value = AuthState.Loading - try { - val user = getMe() - _state.value = AuthState.LoggedIn(user) - } catch (_: Exception) { - // Access token may be expired — try refresh - val refreshed = refreshAccessToken() - if (refreshed) { - try { - val user = getMe() - _state.value = AuthState.LoggedIn(user) - } catch (_: Exception) { - _state.value = AuthState.LoggedOut - } - } else { - _state.value = AuthState.LoggedOut - } - } - } - - // ── Private ────────────────────────────────────────────── - - private fun handleAuthResult(result: TokenResponse) { - setTokens(result.accessToken, result.refreshToken) - saveUser(result.user) - _state.value = AuthState.LoggedIn(result.user) - } - - /** Called by [BLPasskeyManager] after successful passkey authentication. */ - internal fun handleLoginResult(result: TokenResponse) = handleAuthResult(result) - - /** Encode a Map to JSON string. */ - private fun encodeMap(map: Map): String = - client.json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - map, - ) -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt deleted file mode 100644 index 0da5b11..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.bytelyst.platform - -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * Biometric authentication wrapper (Face / Fingerprint). - * - * Uses AndroidX BiometricPrompt for hardware-backed authentication. - * Mirrors the Swift BLBiometricAuth API. - */ -object BLBiometricAuth { - - enum class BiometricResult { - SUCCESS, - CANCELLED, - NOT_AVAILABLE, - ERROR, - } - - /** - * Check if biometric authentication is available on this device. - */ - fun isAvailable(activity: FragmentActivity): Boolean { - val manager = BiometricManager.from(activity) - return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == - BiometricManager.BIOMETRIC_SUCCESS - } - - /** - * Show a biometric prompt and return the result. - * - * Must be called from a FragmentActivity (Compose activities extend this). - */ - suspend fun authenticate( - activity: FragmentActivity, - title: String = "Authenticate", - subtitle: String? = null, - negativeButtonText: String = "Cancel", - ): BiometricResult { - if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE - - return suspendCoroutine { continuation -> - val executor = ContextCompat.getMainExecutor(activity) - - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - continuation.resume(BiometricResult.SUCCESS) - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - val result = if ( - errorCode == BiometricPrompt.ERROR_USER_CANCELED || - errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || - errorCode == BiometricPrompt.ERROR_CANCELED - ) { - BiometricResult.CANCELLED - } else { - BiometricResult.ERROR - } - continuation.resume(result) - } - - override fun onAuthenticationFailed() { - // Individual attempt failed, prompt stays open — don't resume yet - } - } - - val prompt = BiometricPrompt(activity, executor, callback) - val info = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .apply { subtitle?.let { setSubtitle(it) } } - .setNegativeButtonText(negativeButtonText) - .build() - - prompt.authenticate(info) - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt deleted file mode 100644 index 1b6ec9d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.UUID -import java.util.concurrent.TimeUnit - -/** - * Azure Blob Storage client via platform-service SAS tokens. - * - * Flow: Get SAS token from POST /api/blob/sas → Upload directly to Azure Blob. - * Mirrors the Swift BLBlobClient API. - */ -class BLBlobClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - @Serializable - data class SasResponse( - val sasUrl: String, - val blobUrl: String, - ) - - private val json = Json { ignoreUnknownKeys = true } - - private val platformClient = BLPlatformClient(config, tokenProvider) - - private val uploadClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(120, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Upload data to Azure Blob Storage. - * - * @param data The raw bytes to upload. - * @param container Azure Blob container name (e.g., "audio", "attachments"). - * @param fileName The blob name / file path. - * @param contentType MIME type (e.g., "audio/wav", "image/png"). - * @return The public blob URL on success, or null on failure. - */ - suspend fun upload( - data: ByteArray, - container: String, - fileName: String, - contentType: String, - ): String? = withContext(Dispatchers.IO) { - try { - // Step 1: Get SAS token from platform-service - val sasBody = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf( - "container" to container, - "blobName" to fileName, - "permissions" to "w", - "contentType" to contentType, - ), - ) - val sasResponse = platformClient.request("POST", "/api/blob/sas", sasBody) - val sas = json.decodeFromString(sasResponse) - - // Step 2: Upload directly to Azure Blob via SAS URL - val request = Request.Builder() - .url(sas.sasUrl) - .put(data.toRequestBody(contentType.toMediaType())) - .header("x-ms-blob-type", "BlockBlob") - .header("x-ms-blob-content-type", contentType) - .build() - - uploadClient.newCall(request).execute().use { response -> - if (response.isSuccessful) sas.blobUrl else null - } - } catch (_: Exception) { - null - } - } - - /** - * Upload audio data (convenience method). - */ - suspend fun uploadAudio(data: ByteArray, fileName: String): String? { - return upload(data, "audio", fileName, "audio/wav") - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt deleted file mode 100644 index 52d12c2..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBroadcastClient.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -// Top-level enums used by BroadcastUI -enum class MessagePriority { LOW, NORMAL, HIGH, URGENT } -enum class MessageStyle { BANNER, MODAL, TOAST, FULLSCREEN } -enum class MessageStatus { UNREAD, READ, DISMISSED } - -// Top-level type alias for convenience -typealias InAppMessage = BLBroadcastClient.InAppMessage - -/** - * Broadcast Client — In-app message client for Android. - * Part of ByteLystPlatformSDK. - */ -class BLBroadcastClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private var pollingJob: kotlinx.coroutines.Job? = null - - enum class Priority { - LOW, NORMAL, HIGH, URGENT - } - - enum class Style { - BANNER, MODAL, TOAST, FULLSCREEN - } - - enum class Status { - UNREAD, READ, DISMISSED - } - - @Serializable - data class InAppMessage( - val id: String, - val userId: String, - val productId: String, - val broadcastId: String, - val title: String, - val body: String, - val bodyMarkdown: String? = null, - val ctaText: String? = null, - val ctaUrl: String? = null, - val priority: String, - val style: String, - val dismissible: Boolean, - val expiresAt: String? = null, - val imageUrl: String? = null, - val status: String, - val createdAt: String, - val updatedAt: String, - ) - - @Serializable - private data class MessagesResponse( - val messages: List - ) - - @Serializable - private data class ClickResponse( - val success: Boolean, - val redirectUrl: String? = null, - ) - - /** - * List active in-app messages for the current user. - */ - suspend fun getMessages() = listMessages() - - suspend fun listMessages(): Result> = withContext(Dispatchers.IO) { - try { - val request = buildRequest(path = "/broadcasts") - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(MessagesResponse.serializer(), body) - Result.success(result.messages) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Mark a message as read. - */ - suspend fun markRead(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/read", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Mark a message as dismissed. - */ - suspend fun markDismissed(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/dismiss", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Track a CTA click and get the redirect URL. - */ - suspend fun trackClick(messageId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/broadcasts/$messageId/click", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.success(null) - val result = json.decodeFromString(ClickResponse.serializer(), body) - Result.success(result.redirectUrl) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Start polling for new messages. - */ - fun startPolling( - intervalMs: Long = 60000L, - onUpdate: (List) -> Unit, - ) { - stopPolling() - pollingJob = kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - listMessages() - .onSuccess { messages -> onUpdate(messages) } - .onFailure { /* Silently ignore polling errors */ } - delay(intervalMs) - } - } - } - - /** - * Stop polling for messages. - */ - fun stopPolling() { - pollingJob?.cancel() - pollingJob = null - } - - private fun buildRequest( - path: String, - method: String = "GET", - body: String? = null, - ): Request { - val url = "${config.baseUrl}$path" - val token = tokenProvider() ?: "" - - val builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer $token") - .header("x-product-id", config.productId) - .header("x-platform", "android") - .header("x-app-version", config.appVersion) - .header("x-os-version", config.osVersion) - - if (body != null) { - builder.method(method, body.toRequestBody("application/json".toMediaType())) - } else if (method != "GET") { - builder.method(method, "".toRequestBody(null)) - } - - return builder.build() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt deleted file mode 100644 index 515f109..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import java.io.PrintWriter -import java.io.StringWriter -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.UUID - -/** - * Crash reporter — captures uncaught exceptions and reports to telemetry. - * - * Installs a [Thread.UncaughtExceptionHandler] that: - * 1. Saves crash info to SharedPreferences - * 2. On next app launch, sends the crash report to platform-service telemetry - * 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs) - * - * Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android). - */ -class BLCrashReporter( - context: Context, - private val config: BLPlatformConfig, -) { - private val prefs: SharedPreferences = - context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE) - private val client = BLPlatformClient(config) - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val json = Json { encodeDefaults = true } - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - companion object { - private const val KEY_PENDING_CRASH = "pending_crash" - } - - /** - * Install the crash handler and send any pending crash reports. - * Call once from Application.onCreate(). - */ - fun install() { - sendPendingCrashReport() - installHandler() - } - - private fun installHandler() { - val previousHandler = Thread.getDefaultUncaughtExceptionHandler() - - Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - val sw = StringWriter() - throwable.printStackTrace(PrintWriter(sw)) - - val crashData = buildJsonObject { - put("id", UUID.randomUUID().toString()) - put("productId", config.productId) - put("platform", config.platform) - put("timestamp", isoNow()) - put("thread", thread.name) - put("exception", throwable.javaClass.name) - put("message", throwable.message ?: "") - put("stackTrace", sw.toString().take(4096)) - } - - // Persist synchronously (we may be about to die) - prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit() - - // Delegate to previous handler - previousHandler?.uncaughtException(thread, throwable) - } - } - - private fun sendPendingCrashReport() { - val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return - prefs.edit().remove(KEY_PENDING_CRASH).apply() - - scope.launch { - try { - val crashEvent = json.parseToJsonElement(pending) - val payload = buildJsonObject { - put("productId", config.productId) - put("events", JsonArray(listOf(crashEvent))) - } - client.fireAndForget("POST", "/api/telemetry/events", json.encodeToString(payload)) - } catch (_: Exception) { - // Best-effort — don't re-queue - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt deleted file mode 100644 index 34be026..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * Feature flag client for platform-service. - * - * Polls GET /api/flags/poll on a configurable interval and caches results - * in memory. Consumers call [isEnabled] with a flag key. - * - * Mirrors the Swift BLFeatureFlagClient API. - */ -class BLFeatureFlagClient( - private val config: BLPlatformConfig, - private val pollIntervalMs: Long = 5 * 60 * 1000L, -) { - @Serializable - private data class FlagResponse(val flags: Map = emptyMap()) - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config) - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var pollJob: Job? = null - - @Volatile - private var flags: Map = emptyMap() - - private var userId: String? = null - - // ── Public API ─────────────────────────────────────────── - - /** - * Initialize and start polling. Call once at app startup. - */ - fun init(userId: String? = null) { - this.userId = userId - scope.launch { fetchFlags() } - startPolling() - } - - fun isEnabled(key: String): Boolean = flags[key] == true - - fun getAllFlags(): Map = flags.toMap() - - /** - * Force a refresh of feature flags. - */ - suspend fun refresh() { - fetchFlags() - } - - fun stop() { - pollJob?.cancel() - pollJob = null - } - - // ── Private ────────────────────────────────────────────── - - private fun startPolling() { - if (pollJob != null) return - pollJob = scope.launch { - while (isActive) { - delay(pollIntervalMs) - fetchFlags() - } - } - } - - private suspend fun fetchFlags() { - try { - val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } - val qs = buildString { - append("?platform=${enc(config.platform)}") - userId?.let { append("&userId=${enc(it)}") } - } - val response = client.request("GET", "/api/flags/poll$qs", skipAuth = true) - val result = json.decodeFromString(response) - flags = result.flags - } catch (_: Exception) { - // Keep existing flags on failure - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt deleted file mode 100644 index 6fefb58..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeedbackClient.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.graphics.Bitmap -import android.view.View -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.ByteArrayOutputStream -import java.util.Locale -import java.util.concurrent.TimeUnit - -/** - * Feedback client for submitting user feedback with optional screenshots. - * - * TODO-3: Full implementation for Android - * - * Flow: - * 1. Capture screenshot (optional) - * 2. Get SAS URL for upload - * 3. Upload screenshot to blob storage - * 4. Submit feedback with metadata - */ -class BLFeedbackClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - enum class FeedbackType { - BUG, FEATURE, PRAISE, OTHER - } - - enum class ScreenshotFormat { - PNG, JPEG, WEBP - } - - data class DeviceContext( - val osVersion: String, - val appVersion: String, - val deviceModel: String, - val screenResolution: String, - val locale: String, - ) { - companion object { - fun fromContext(context: Context): DeviceContext { - val displayMetrics = context.resources.displayMetrics - return DeviceContext( - osVersion = android.os.Build.VERSION.RELEASE, - appVersion = context.packageManager.getPackageInfo( - context.packageName, 0 - ).versionName ?: "unknown", - deviceModel = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}", - screenResolution = "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}", - locale = Locale.getDefault().toString(), - ) - } - } - } - - @Serializable - data class SasResponse( - val blobPath: String, - val uploadUrl: String, - val expiresIn: Int, - val maxSizeBytes: Int, - ) - - @Serializable - data class FeedbackResponse( - val id: String, - val productId: String, - val userId: String, - val type: String, - val title: String, - val status: String, - val createdAt: String, - val screenshotBlobPath: String? = null, - ) - - data class FeedbackParams( - val type: FeedbackType, - val title: String, - val body: String? = null, - val screen: String? = null, - val rating: Int? = null, - val screenshot: Pair? = null, - val deviceContext: DeviceContext? = null, - ) - - private val json = Json { ignoreUnknownKeys = true } - private val platformClient = BLPlatformClient(config, tokenProvider) - private val uploadClient = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) - .writeTimeout(120, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - /** - * Submit feedback with optional screenshot. - * - * TODO-3: Full implementation - */ - suspend fun submitFeedback(params: FeedbackParams): FeedbackResponse? = withContext(Dispatchers.IO) { - try { - // Step 1: Handle screenshot upload if provided - var screenshotMeta: Triple? = null - params.screenshot?.let { (data, format) -> - val contentType = when (format) { - ScreenshotFormat.PNG -> "image/png" - ScreenshotFormat.JPEG -> "image/jpeg" - ScreenshotFormat.WEBP -> "image/webp" - } - - // Get SAS URL - val sas = generateSASUrl(contentType) ?: return@withContext null - - // Upload screenshot - val uploaded = uploadScreenshot(data, sas.uploadUrl, contentType) - if (!uploaded) return@withContext null - - screenshotMeta = Triple(sas.blobPath, contentType, data.size) - } - - // Step 2: Submit feedback - val body = buildMap { - put("type", params.type.name.lowercase()) - put("title", params.title) - params.body?.let { put("body", it) } - params.screen?.let { put("screen", it) } - params.rating?.let { put("rating", it) } - screenshotMeta?.let { (path, type, size) -> - put("screenshotBlobPath", path) - put("screenshotContentType", type) - put("screenshotSizeBytes", size) - } - params.deviceContext?.let { ctx -> - put("deviceContext", mapOf( - "osVersion" to ctx.osVersion, - "appVersion" to ctx.appVersion, - "deviceModel" to ctx.deviceModel, - "screenResolution" to ctx.screenResolution, - "locale" to ctx.locale, - )) - } - } - - // TODO-3: Implement actual API call - throw NotImplementedError( - "submitFeedback API call not yet implemented. " + - "Use platformClient.request(\"POST\", \"/api/feedback\", jsonBody)" - ) - } catch (_: Exception) { - null - } - } - - /** - * Capture screenshot and submit feedback in one operation. - * - * TODO-3: Full implementation using MediaProjection or View.draw() - */ - suspend fun captureAndSubmit( - context: Context, - type: FeedbackType, - title: String, - body: String? = null, - ): FeedbackResponse? { - throw NotImplementedError( - "captureAndSubmit not yet implemented.\n\n" + - "To implement:\n" + - "1. Option A - MediaProjection API (requires permission):\n" + - " - Request MediaProjection permission\n" + - " - Use MediaProjection.createVirtualDisplay()\n" + - " - Capture ImageReader frame\n\n" + - "2. Option B - View.draw() (limited to app window):\n" + - " - val view = window.decorView.rootView\n" + - " - val bitmap = Bitmap.createBitmap(view.width, view.height)\n" + - " - val canvas = Canvas(bitmap)\n" + - " - view.draw(canvas)\n\n" + - "3. Convert Bitmap to ByteArray\n" + - "4. Call submitFeedback with screenshot" - ) - } - - /** - * Capture current screen as Bitmap. - * - * TODO-3: Full implementation - */ - fun captureScreen(): Bitmap { - throw NotImplementedError( - "captureScreen requires MediaProjection API. " + - "See: https://developer.android.com/reference/android/media/projection/MediaProjection" - ) - } - - /** - * Capture specific View as Bitmap. - * - * TODO-3: Full implementation - */ - fun captureView(view: View): Bitmap { - val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) - val canvas = android.graphics.Canvas(bitmap) - view.draw(canvas) - return bitmap - } - - /** - * Convert Bitmap to PNG ByteArray. - */ - fun bitmapToBytes(bitmap: Bitmap, format: ScreenshotFormat = ScreenshotFormat.PNG): ByteArray { - val androidFormat = when (format) { - ScreenshotFormat.PNG -> Bitmap.CompressFormat.PNG - ScreenshotFormat.JPEG -> Bitmap.CompressFormat.JPEG - ScreenshotFormat.WEBP -> Bitmap.CompressFormat.WEBP_LOSSY - } - val stream = ByteArrayOutputStream() - bitmap.compress(androidFormat, 90, stream) - return stream.toByteArray() - } - - // MARK: - Private - - private suspend fun generateSASUrl(contentType: String): SasResponse? = withContext(Dispatchers.IO) { - try { - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("contentType" to contentType), - ) - val response = platformClient.request("POST", "/api/feedback/sas", body) - json.decodeFromString(SasResponse.serializer(), response) - } catch (_: Exception) { - null - } - } - - private suspend fun uploadScreenshot( - data: ByteArray, - sasUrl: String, - contentType: String, - ): Boolean = withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url(sasUrl) - .put(data.toRequestBody(contentType.toMediaType())) - .header("x-ms-blob-type", "BlockBlob") - .header("x-ms-blob-content-type", contentType) - .build() - - uploadClient.newCall(request).execute().use { response -> - response.isSuccessful - } - } catch (_: Exception) { - false - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt deleted file mode 100644 index 318276e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFieldEncrypt.kt +++ /dev/null @@ -1,253 +0,0 @@ -package com.bytelyst.platform - -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec -import java.security.SecureRandom - -/** - * Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). - * All byte arrays are hex-encoded strings for JSON serialization. - */ -data class BLEncryptedField( - /** Sentinel — always true for encrypted fields. */ - val __encrypted: Boolean = true, - /** Schema version (currently 1). */ - val v: Int = 1, - /** Algorithm identifier. */ - val alg: String = "aes-256-gcm", - /** Ciphertext (hex-encoded). */ - val ct: String, - /** Initialization vector (hex-encoded, 12 bytes = 24 hex chars). */ - val iv: String, - /** GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). */ - val tag: String, - /** DEK identifier — identifies which key was used for encryption. */ - val dekId: String, -) - -/** - * AES-256-GCM field-level encryption. - * - * Produces [BLEncryptedField] objects that are wire-compatible with the - * TypeScript `@bytelyst/field-encrypt` package. Backends and native clients - * can encrypt/decrypt the same fields interchangeably. - * - * Usage: - * ```kotlin - * val key = BLFieldEncrypt.generateKey() - * val encrypted = BLFieldEncrypt.encrypt("sensitive data", key, "dek_user1_notes") - * val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - * ``` - */ -object BLFieldEncrypt { - - private const val ALGORITHM = "AES/GCM/NoPadding" - private const val KEY_SIZE_BYTES = 32 - private const val KEY_SIZE_BITS = KEY_SIZE_BYTES * 8 - private const val IV_SIZE_BYTES = 12 - private const val TAG_SIZE_BITS = 128 - - private val secureRandom = SecureRandom() - - // ── Encrypt ───────────────────────────────────────────── - - /** - * Encrypt a plaintext string with AES-256-GCM. - * - * @param plaintext UTF-8 string to encrypt. - * @param key 32-byte AES secret key. - * @param dekId DEK identifier stored in the output for key lookup on decrypt. - * @param aad Optional additional authenticated data (e.g., "userId:context"). - * @return [BLEncryptedField] with hex-encoded ciphertext, IV, and tag. - * @throws IllegalArgumentException if key size is wrong. - */ - fun encrypt( - plaintext: String, - key: SecretKey, - dekId: String, - aad: String? = null, - ): BLEncryptedField { - require(key.encoded.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" - } - - val iv = ByteArray(IV_SIZE_BYTES).also { secureRandom.nextBytes(it) } - - val cipher = Cipher.getInstance(ALGORITHM) - val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) - cipher.init(Cipher.ENCRYPT_MODE, key, spec) - - if (aad != null) { - cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) - } - - // GCM output = ciphertext || tag (last 16 bytes) - val ciphertextWithTag = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) - val tagOffset = ciphertextWithTag.size - TAG_SIZE_BITS / 8 - val ct = ciphertextWithTag.copyOfRange(0, tagOffset) - val tag = ciphertextWithTag.copyOfRange(tagOffset, ciphertextWithTag.size) - - return BLEncryptedField( - ct = ct.toHexString(), - iv = iv.toHexString(), - tag = tag.toHexString(), - dekId = dekId, - ) - } - - // ── Decrypt ───────────────────────────────────────────── - - /** - * Decrypt a [BLEncryptedField] back to plaintext. - * - * @param field Encrypted field object (from Cosmos DB or API response). - * @param key 32-byte AES secret key (must match the key used to encrypt). - * @param aad Optional AAD (must match the AAD used during encryption). - * @return Decrypted UTF-8 string. - * @throws javax.crypto.AEADBadTagException if authentication fails (tampered data or wrong key). - */ - fun decrypt( - field: BLEncryptedField, - key: SecretKey, - aad: String? = null, - ): String { - require(key.encoded.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${key.encoded.size}" - } - - val iv = field.iv.hexToByteArray() - val ct = field.ct.hexToByteArray() - val tag = field.tag.hexToByteArray() - - // GCM expects ciphertext || tag as input - val ciphertextWithTag = ct + tag - - val cipher = Cipher.getInstance(ALGORITHM) - val spec = GCMParameterSpec(TAG_SIZE_BITS, iv) - cipher.init(Cipher.DECRYPT_MODE, key, spec) - - if (aad != null) { - cipher.updateAAD(aad.toByteArray(Charsets.UTF_8)) - } - - val plaintextBytes = cipher.doFinal(ciphertextWithTag) - return String(plaintextBytes, Charsets.UTF_8) - } - - // ── Key Generation ────────────────────────────────────── - - /** - * Generate a random 32-byte AES-256 secret key. - */ - fun generateKey(): SecretKey { - val keyGen = KeyGenerator.getInstance("AES") - keyGen.init(KEY_SIZE_BITS, secureRandom) - return keyGen.generateKey() - } - - /** - * Create a [SecretKey] from a hex-encoded string (64 hex chars = 32 bytes). - */ - fun keyFromHex(hex: String): SecretKey { - val bytes = hex.hexToByteArray() - require(bytes.size == KEY_SIZE_BYTES) { - "AES-256-GCM requires a $KEY_SIZE_BYTES-byte key, got ${bytes.size}" - } - return SecretKeySpec(bytes, "AES") - } - - // ── Secure Store Key Derivation ──────────────────────── - - /** - * Get or create a persistent encryption key in [BLSecureStore]. - * - * On first call, generates a random 32-byte AES-256 key and stores it - * as a hex string in EncryptedSharedPreferences. On subsequent calls, - * loads the existing key. This provides a stable per-device DEK for - * client-side encryption without requiring the backend to provision keys. - * - * @param store The [BLSecureStore] instance for the current app. - * @param account Storage key name (default: `"field_encrypt_dek"`). - * @return A 32-byte [SecretKey] backed by secure storage. - */ - fun getOrCreateKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey { - val existingHex = store.read(account) - if (existingHex != null) { - return keyFromHex(existingHex) - } - - val newKey = generateKey() - val hex = newKey.encoded.toHexString() - store.save(account, hex) - return newKey - } - - /** - * Load an existing encryption key from [BLSecureStore] without creating one. - * - * @param store The [BLSecureStore] instance. - * @param account Storage key name (default: `"field_encrypt_dek"`). - * @return The stored [SecretKey], or `null` if none exists. - */ - fun loadKey(store: BLSecureStore, account: String = "field_encrypt_dek"): SecretKey? { - val hex = store.read(account) ?: return null - return try { - keyFromHex(hex) - } catch (_: IllegalArgumentException) { - null - } - } - - /** - * Delete the stored encryption key from [BLSecureStore]. - * - * @param store The [BLSecureStore] instance. - * @param account Storage key name. - * @return `true` if the key was deleted. - */ - fun deleteKey(store: BLSecureStore, account: String = "field_encrypt_dek"): Boolean { - return store.delete(account) - } - - // ── Type Guard ────────────────────────────────────────── - - /** - * Check if a JSON-like map represents an encrypted field. - * Compatible with the TypeScript `isEncryptedField()` type guard. - */ - fun isEncrypted(value: Map?): Boolean { - if (value == null) return false - return value["__encrypted"] == true && - value["v"] != null && - value["alg"] != null && - value["ct"] != null && - value["iv"] != null && - value["tag"] != null && - value["dekId"] != null - } - - /** - * Check if a [BLEncryptedField] is valid. - */ - fun isEncrypted(field: BLEncryptedField?): Boolean { - return field?.__encrypted == true - } -} - -// ── Hex Helpers ───────────────────────────────────────────── - -/** Hex-encode a byte array to a lowercase string. */ -fun ByteArray.toHexString(): String = - joinToString("") { "%02x".format(it) } - -/** Decode a hex-encoded string to a byte array. */ -fun String.hexToByteArray(): ByteArray { - require(length % 2 == 0) { "Hex string must have even length, got $length" } - return ByteArray(length / 2) { i -> - val offset = i * 2 - substring(offset, offset + 2).toInt(16).toByte() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt deleted file mode 100644 index b05d172..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * Kill switch client for platform-service. - * - * Checks GET /api/settings/kill-switch to determine if the app should be disabled. - * Fail-open: returns [KillSwitchResult.ok] on any error. - * - * Mirrors the Swift BLKillSwitchClient API. - */ -class BLKillSwitchClient( - private val config: BLPlatformConfig, -) { - @Serializable - data class KillSwitchResult( - val disabled: Boolean = false, - val message: String? = null, - ) { - companion object { - fun ok() = KillSwitchResult(disabled = false, message = null) - } - } - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config) - - /** - * Check if the app is disabled. Fail-open on any error. - */ - suspend fun check(): KillSwitchResult { - return try { - val enc = { v: String -> URLEncoder.encode(v, "UTF-8") } - val response = client.request("GET", "/api/settings/kill-switch?productId=${enc(config.productId)}&platform=${enc(config.platform)}", skipAuth = true) - json.decodeFromString(response) - } catch (_: Exception) { - KillSwitchResult.ok() - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt deleted file mode 100644 index 417141f..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import java.net.URLEncoder - -/** - * License client for platform-service. - * - * Activates and checks license keys via /api/licenses endpoints. - * URL-encodes the license key in the path to handle special characters. - * - * Mirrors the Swift BLLicenseClient API. - */ -class BLLicenseClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - @Serializable - data class LicenseStatus( - val valid: Boolean = false, - val plan: String = "free", - val expiresAt: String? = null, - val message: String? = null, - ) - - @Serializable - data class ActivationResult( - val success: Boolean = false, - val plan: String = "free", - val message: String? = null, - ) - - private val json = Json { ignoreUnknownKeys = true } - private val client = BLPlatformClient(config, tokenProvider) - - /** - * Activate a license key. - */ - suspend fun activate(licenseKey: String): ActivationResult { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("productId" to config.productId), - ) - val response = client.request("POST", "/api/licenses/$encoded/activate", body) - json.decodeFromString(response) - } catch (e: Exception) { - ActivationResult(success = false, message = e.message) - } - } - - /** - * Check the status of a license key. - */ - suspend fun checkStatus(licenseKey: String): LicenseStatus { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - val response = client.request("GET", "/api/licenses/$encoded/status") - json.decodeFromString(response) - } catch (e: Exception) { - LicenseStatus(valid = false, message = e.message) - } - } - - /** - * Deactivate a license key. - */ - suspend fun deactivate(licenseKey: String): Boolean { - return try { - val encoded = URLEncoder.encode(licenseKey, "UTF-8") - client.request("POST", "/api/licenses/$encoded/deactivate") - true - } catch (_: Exception) { - false - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt deleted file mode 100644 index a14f596..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPasskeyManager.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.CredentialManager -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.PublicKeyCredential -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive - -/** - * Passkey manager wrapping Android Credential Manager API. - * - * Handles FIDO2/WebAuthn passkey registration and authentication - * by coordinating between the platform-service backend and the - * Android Credential Manager. - * - * Usage: - * ```kotlin - * val manager = BLPasskeyManager(context, authClient) - * // Register a new passkey - * manager.registerPasskey("My Pixel 9") - * // Authenticate with an existing passkey - * val user = manager.authenticateWithPasskey() - * ``` - */ -class BLPasskeyManager( - private val context: Context, - private val authClient: BLAuthClient, -) { - private val credentialManager = CredentialManager.create(context) - private val json get() = authClient.client.json - - /** - * Register a new passkey for the current user. - * - * 1. Fetches registration options from backend - * 2. Invokes Credential Manager to create credential - * 3. Sends attestation response to backend for verification - * - * @param friendlyName Human-readable name for this passkey (e.g. "Pixel 9") - * @throws Exception if any step fails - */ - suspend fun registerPasskey(friendlyName: String) { - // Step 1: Get registration options from backend - val optionsResponse = authClient.client.request( - "POST", - "/api/auth/passkeys/register/options", - ) - - // Step 2: Create credential via Credential Manager - val request = CreatePublicKeyCredentialRequest( - requestJson = optionsResponse, - ) - val result = credentialManager.createCredential(context, request) - val credential = result as? androidx.credentials.CreatePublicKeyCredentialResponse - ?: throw IllegalStateException("Unexpected credential type") - - // Step 3: Send attestation to backend - val attestationJson = credential.registrationResponseJson - // Append friendlyName to the response - val bodyObj = json.decodeFromString(attestationJson) - val mutableMap = bodyObj.toMutableMap() - mutableMap["friendlyName"] = kotlinx.serialization.json.JsonPrimitive(friendlyName) - val body = json.encodeToString(JsonObject.serializer(), JsonObject(mutableMap)) - - authClient.client.request( - "POST", - "/api/auth/passkeys/register/verify", - body, - ) - } - - /** - * Authenticate using an existing passkey. - * - * 1. Fetches authentication options from backend - * 2. Invokes Credential Manager to select and sign with credential - * 3. Sends assertion response to backend for verification - * 4. Returns authenticated user and stores tokens - * - * @return Authenticated user - * @throws Exception if any step fails - */ - suspend fun authenticateWithPasskey(): BLAuthClient.AuthUser { - // Step 1: Get authentication options from backend - val optionsResponse = authClient.client.request( - "POST", - "/api/auth/passkeys/authenticate/options", - skipAuth = true, - ) - - // Step 2: Get credential via Credential Manager - val getRequest = GetCredentialRequest( - listOf(GetPublicKeyCredentialOption(requestJson = optionsResponse)), - ) - val result = credentialManager.getCredential(context, getRequest) - val credential = result.credential as? PublicKeyCredential - ?: throw IllegalStateException("Unexpected credential type") - - // Step 3: Send assertion to backend - val assertionJson = credential.authenticationResponseJson - val response = authClient.client.request( - "POST", - "/api/auth/passkeys/authenticate/verify", - assertionJson, - skipAuth = true, - ) - - // Step 4: Parse tokens and update auth state - val tokenResult = json.decodeFromString(response) - // Use reflection-free approach: directly set tokens - authClient.handleLoginResult(tokenResult) - return tokenResult.user - } - - /** - * List registered passkeys for the current user. - */ - suspend fun listPasskeys(): List { - val response = authClient.client.request("GET", "/api/auth/passkeys") - return json.decodeFromString>(response) - } - - /** - * Delete a passkey (requires step-up authentication). - */ - suspend fun deletePasskey(passkeyId: String) { - authClient.client.request("DELETE", "/api/auth/passkeys/$passkeyId") - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt deleted file mode 100644 index cc8334a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.UUID -import java.util.concurrent.TimeUnit - -/** - * Generic HTTP client for platform-service. - * - * Injects auth token, x-product-id, and x-request-id on every request. - * Supports fire-and-forget mode for telemetry-style calls. - */ -class BLPlatformClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) - .build() - - /** - * Execute a request and return the response body as a String. - * Throws on non-2xx responses. - */ - suspend fun request( - method: String, - path: String, - body: String? = null, - extraHeaders: Map = emptyMap(), - skipAuth: Boolean = false, - ): String = withContext(Dispatchers.IO) { - val builder = Request.Builder() - .url("${config.baseUrl}$path") - .header("Content-Type", "application/json") - .header("X-Product-Id", config.productId) - .header("X-Request-Id", UUID.randomUUID().toString()) - - if (!skipAuth) { - tokenProvider()?.let { token -> - builder.header("Authorization", "Bearer $token") - } - } - - extraHeaders.forEach { (k, v) -> builder.header(k, v) } - - val requestBody = body?.toRequestBody("application/json".toMediaType()) - when (method.uppercase()) { - "GET" -> builder.get() - "POST" -> builder.post(requestBody ?: "".toRequestBody(null)) - "PUT" -> builder.put(requestBody ?: "".toRequestBody(null)) - "DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete() - "PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null)) - else -> builder.method(method, requestBody) - } - - val response = httpClient.newCall(builder.build()).execute() - val responseBody = response.body?.string() ?: "" - - if (!response.isSuccessful) { - throw BLApiException(response.code, responseBody) - } - - responseBody - } - - /** - * Fire-and-forget: execute request, silently swallow errors. - */ - suspend fun fireAndForget( - method: String, - path: String, - body: String? = null, - extraHeaders: Map = emptyMap(), - ) { - try { - request(method, path, body, extraHeaders) - } catch (_: Exception) { - // Silently swallow — fire-and-forget - } - } -} - -/** - * Exception thrown when the platform API returns a non-2xx status. - */ -class BLApiException( - val statusCode: Int, - val responseBody: String, -) : Exception("Platform API error $statusCode: $responseBody") diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt deleted file mode 100644 index 62520dd..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.bytelyst.platform - -/** - * Product-specific configuration for the ByteLyst platform SDK. - * - * Each Android app creates one instance at startup and passes it to - * all SDK components via DI (Hilt, Koin, or manual). - */ -data class BLPlatformConfig( - /** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */ - val productId: String, - - /** Platform-service base URL (e.g., "http://localhost:4003/api"). */ - val baseUrl: String, - - /** Platform string for telemetry (e.g., "android", "wear_os"). */ - val platform: String = "android", - - /** Channel string for telemetry (e.g., "native", "keyboard"). */ - val channel: String = "native", - - /** Application ID / bundle ID (e.g., "com.chronomind.app"). */ - val applicationId: String, - - /** App version string for telemetry headers (e.g., "1.2.0"). */ - val appVersion: String = "0.0.0", - - /** OS version string for telemetry headers (e.g., "14"). */ - val osVersion: String = "unknown", - - /** Request timeout in milliseconds. Default: 15 seconds. */ - val timeoutMs: Long = 15_000L, -) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt deleted file mode 100644 index 2550bea..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys - -/** - * EncryptedSharedPreferences-backed secure storage. - * - * Replaces Keychain on iOS. Uses Android Keystore for key material. - * Each app gets its own namespace via [applicationId]. - */ -class BLSecureStore( - context: Context, - applicationId: String, -) { - private val prefs: SharedPreferences = try { - val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - EncryptedSharedPreferences.create( - "${applicationId}_secure_store", - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } catch (_: Exception) { - // Fallback to plain SharedPreferences if encryption fails (e.g., test environment) - context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE) - } - - fun save(key: String, value: String): Boolean { - return prefs.edit().putString(key, value).commit() - } - - fun read(key: String): String? { - return prefs.getString(key, null) - } - - fun delete(key: String): Boolean { - return prefs.edit().remove(key).commit() - } - - fun clear(): Boolean { - return prefs.edit().clear().commit() - } - - fun contains(key: String): Boolean { - return prefs.contains(key) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt deleted file mode 100644 index 2158714..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSurveyClient.kt +++ /dev/null @@ -1,367 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -/** - * Survey Client — In-app survey client for Android. - * Part of ByteLystPlatformSDK. - */ -class BLSurveyClient( - private val config: BLPlatformConfig, - private val tokenProvider: () -> String? = { null }, -) { - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private var pollingJob: Job? = null - private val responseCache = mutableMapOf() - - enum class QuestionType { - SINGLE_CHOICE, MULTIPLE_CHOICE, RATING, NPS, TEXT_SHORT, TEXT_LONG, DROPDOWN, SCALE, RANKING - } - - @Serializable - data class QuestionOption( - val id: String, - val text: String, - val emoji: String? = null, - ) - - @Serializable - data class Question( - val id: String, - val type: String, - val text: String, - val description: String? = null, - val required: Boolean, - val options: List? = null, - val minLength: Int? = null, - val maxLength: Int? = null, - val minValue: Int? = null, - val maxValue: Int? = null, - ) - - @Serializable - data class SurveyIncentive( - val type: String, - val amount: Int, - ) - - @Serializable - data class SurveyTrigger( - val type: String, - val seconds: Int? = null, - val eventName: String? = null, - val pagePattern: String? = null, - ) - - @Serializable - data class ActiveSurvey( - val id: String, - val title: String, - val description: String? = null, - val questions: List, - val incentive: SurveyIncentive? = null, - val displayTrigger: SurveyTrigger, - ) - - @Serializable - data class SurveyAnswer( - val type: String, - val value: JsonObject, - ) - - @Serializable - data class SurveyResponse( - val id: String, - val surveyId: String, - val userId: String, - val answers: Map, - val currentQuestionIndex: Int, - val startedAt: String, - val completedAt: String? = null, - val isComplete: Boolean, - val incentiveClaimed: Boolean, - val incentiveClaimedAt: String? = null, - val createdAt: String, - val updatedAt: String, - ) - - @Serializable - private data class ActiveSurveyResponse( - val survey: ActiveSurvey?, - ) - - @Serializable - private data class StartSurveyResponse( - val responseId: String, - val startedAt: String, - val currentQuestionIndex: Int, - val answers: Map, - ) - - @Serializable - private data class SubmitAnswerResponse( - val responseId: String, - val currentQuestionIndex: Int, - val answers: Map, - ) - - @Serializable - data class SurveyCompletionResult( - val success: Boolean, - val timeSpentSeconds: Int, - val incentiveClaimed: Boolean, - ) - - /** - * Get active survey for the current user (if any). - */ - suspend fun getActiveSurvey(): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest(path = "/surveys/active") - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(ActiveSurveyResponse.serializer(), body) - Result.success(result.survey) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Start a survey session. - */ - suspend fun startSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/start", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(StartSurveyResponse.serializer(), body) - - val surveyResponse = SurveyResponse( - id = result.responseId, - surveyId = surveyId, - userId = "", - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - startedAt = result.startedAt, - completedAt = null, - isComplete = false, - incentiveClaimed = false, - incentiveClaimedAt = null, - createdAt = result.startedAt, - updatedAt = result.startedAt, - ) - - // Cache the response - responseCache[surveyId] = surveyResponse - Result.success(surveyResponse) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Submit an answer to a survey question. - */ - suspend fun submitAnswer( - surveyId: String, - questionId: String, - answer: SurveyAnswer, - ): Result = withContext(Dispatchers.IO) { - try { - val body = json.encodeToString( - SubmitAnswerRequest.serializer(), - SubmitAnswerRequest(questionId, answer) - ) - - val request = buildRequest( - path = "/surveys/$surveyId/response", - method = "POST", - body = body - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(SubmitAnswerResponse.serializer(), responseBody) - - // Update cache - val cached = responseCache[surveyId] - if (cached != null) { - val updated = cached.copy( - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - ) - responseCache[surveyId] = updated - } - - Result.success( - SurveyResponse( - id = result.responseId, - surveyId = surveyId, - userId = "", - answers = result.answers, - currentQuestionIndex = result.currentQuestionIndex, - startedAt = "", - completedAt = null, - isComplete = false, - incentiveClaimed = false, - incentiveClaimedAt = null, - createdAt = "", - updatedAt = java.time.Instant.now().toString(), - ) - ) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Complete a survey. - */ - suspend fun completeSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/complete", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - val body = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - val result = json.decodeFromString(SurveyCompletionResult.serializer(), body) - - // Clear cache on completion - responseCache.remove(surveyId) - Result.success(result) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Dismiss a survey (won't show again). - */ - suspend fun dismissSurvey(surveyId: String): Result = withContext(Dispatchers.IO) { - try { - val request = buildRequest( - path = "/surveys/$surveyId/dismiss", - method = "POST" - ) - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext Result.failure(Exception("HTTP ${response.code}")) - } - - // Clear cache - responseCache.remove(surveyId) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Get cached response for a survey. - */ - fun getCachedResponse(surveyId: String): SurveyResponse? { - return responseCache[surveyId] - } - - /** - * Start polling for eligible surveys. - */ - fun startPolling( - intervalMs: Long = 60000L, - onUpdate: (ActiveSurvey?) -> Unit, - ) { - stopPolling() - pollingJob = CoroutineScope(Dispatchers.IO).launch { - while (isActive) { - getActiveSurvey() - .onSuccess { survey -> onUpdate(survey) } - .onFailure { /* Silently ignore polling errors */ } - delay(intervalMs) - } - } - } - - /** - * Stop polling for surveys. - */ - fun stopPolling() { - pollingJob?.cancel() - pollingJob = null - } - - @Serializable - private data class SubmitAnswerRequest( - val questionId: String, - val answer: SurveyAnswer, - ) - - private fun buildRequest( - path: String, - method: String = "GET", - body: String? = null, - ): Request { - val url = "${config.baseUrl}$path" - val token = tokenProvider() ?: "" - - val builder = Request.Builder() - .url(url) - .header("Authorization", "Bearer $token") - .header("x-product-id", config.productId) - .header("x-platform", "android") - .header("x-app-version", config.appVersion) - .header("x-os-version", config.osVersion) - - if (body != null) { - builder.method(method, body.toRequestBody("application/json".toMediaType())) - } else if (method != "GET") { - builder.method(method, "".toRequestBody(null)) - } - - return builder.build() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt deleted file mode 100644 index 060cbaf..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Generic offline-first sync engine. - * - * Apps implement [BLSyncAdapter] for their specific data type, - * then [BLSyncEngine] handles the pull-merge-push cycle. - * - * Mirrors the Swift BLSyncEngine + BLSyncAdapter API. - */ -class BLSyncEngine( - private val adapter: BLSyncAdapter, - private val config: BLPlatformConfig, - tokenProvider: () -> String? = { null }, -) { - private val client = BLPlatformClient(config, tokenProvider) - - /** - * Run a full sync cycle: pull → merge → push. - * - * @return [SyncResult] with counts of pulled, pushed, and conflicted items. - */ - suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { - var pulled = 0 - var pushed = 0 - var conflicts = 0 - - try { - // Step 1: Pull remote changes - val lastSync = adapter.getLastSyncTimestamp() - val pullPath = adapter.pullPath(lastSync) - val response = client.request("GET", pullPath) - val remoteItems = adapter.deserializePullResponse(response) - pulled = remoteItems.size - - // Step 2: Merge remote into local - for (item in remoteItems) { - val merged = adapter.merge(item) - if (!merged) conflicts++ - } - - // Step 3: Push local changes - val localChanges = adapter.getLocalChanges() - for (item in localChanges) { - try { - val body = adapter.serializeForPush(item) - val method = adapter.pushMethod(item) - val path = adapter.pushPath(item) - client.request(method, path, body) - adapter.markSynced(item) - pushed++ - } catch (_: Exception) { - // Individual push failure — will retry next sync - } - } - - // Step 4: Update last sync timestamp - adapter.setLastSyncTimestamp(System.currentTimeMillis()) - } catch (_: Exception) { - // Full sync failure — will retry next time - } - - SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts) - } - - data class SyncResult( - val pulled: Int = 0, - val pushed: Int = 0, - val conflicts: Int = 0, - ) -} - -/** - * Adapter interface for [BLSyncEngine]. - * - * Each app implements this for their domain model (timers, sessions, etc.). - */ -interface BLSyncAdapter { - /** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */ - fun getLastSyncTimestamp(): Long? - - /** Set the last sync timestamp after a successful sync. */ - fun setLastSyncTimestamp(timestamp: Long) - - /** Build the pull endpoint path, optionally including a since parameter. */ - fun pullPath(since: Long?): String - - /** Deserialize the pull response JSON into a list of remote items. */ - fun deserializePullResponse(json: String): List - - /** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */ - fun merge(remoteItem: T): Boolean - - /** Get local items that have been modified since last sync. */ - fun getLocalChanges(): List - - /** Serialize a local item for push. */ - fun serializeForPush(item: T): String - - /** HTTP method for pushing an item (POST for new, PUT for update). */ - fun pushMethod(item: T): String - - /** Build the push endpoint path for an item. */ - fun pushPath(item: T): String - - /** Mark an item as synced (clear dirty flag). */ - fun markSynced(item: T) -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt deleted file mode 100644 index 35a061d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.UUID - -/** - * Telemetry client for platform-service. - * - * Queues events locally and flushes in batches to POST /api/telemetry/events. - * Events persist in SharedPreferences to survive process death. - * Fire-and-forget: errors never surface to the user. - * - * Mirrors the Swift BLTelemetryClient API. - */ -class BLTelemetryClient( - private val config: BLPlatformConfig, - context: Context, - private val maxQueueSize: Int = 50, - private val flushIntervalMs: Long = 30_000L, -) { - // ── Event schema ───────────────────────────────────────── - - @Serializable - data class TelemetryEvent( - val id: String, - val productId: String, - val anonymousInstallId: String, - val sessionId: String, - val platform: String, - val channel: String, - val osFamily: String, - val osVersion: String, - val appVersion: String, - val buildNumber: String, - val releaseChannel: String, - val eventType: String, - val module: String, - val eventName: String, - val feature: String? = null, - val message: String? = null, - val errorCode: String? = null, - val errorDomain: String? = null, - val tags: Map? = null, - val metrics: Map? = null, - val occurredAt: String, - ) - - @Serializable - private data class EventBatch(val events: List) - - // ── State ──────────────────────────────────────────────── - - private val prefs: SharedPreferences = - context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - private val queue = mutableListOf() - private var flushJob: Job? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private var sessionId = UUID.randomUUID().toString() - - private val client = BLPlatformClient(config) - - private val installId: String by lazy { - val key = "install_id" - prefs.getString(key, null) ?: UUID.randomUUID().toString().also { - prefs.edit().putString(key, it).apply() - } - } - - private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" - private val appVersion: String - private val buildNumber: String - - init { - val pm = context.packageManager - val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null } - appVersion = pi?.versionName ?: "0.0.0" - buildNumber = (pi?.longVersionCode ?: 0).toString() - } - - private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** Thread-safe ISO timestamp — SimpleDateFormat is NOT thread-safe. */ - @Synchronized - private fun isoNow(): String = isoFormat.format(Date()) - - // ── Lifecycle ──────────────────────────────────────────── - - fun start() { - if (flushJob != null) return - flushJob = scope.launch { - while (isActive) { - delay(flushIntervalMs) - flush() - } - } - } - - fun stop() { - flush() - flushJob?.cancel() - flushJob = null - } - - fun newSession() { - sessionId = UUID.randomUUID().toString() - } - - // ── Public API ─────────────────────────────────────────── - - fun trackEvent( - eventType: String, - module: String, - name: String, - feature: String? = null, - message: String? = null, - errorCode: String? = null, - errorDomain: String? = null, - tags: Map? = null, - metrics: Map? = null, - ) { - val event = TelemetryEvent( - id = UUID.randomUUID().toString(), - productId = config.productId, - anonymousInstallId = installId, - sessionId = sessionId, - platform = config.platform, - channel = config.channel, - osFamily = "android", - osVersion = osVersion, - appVersion = appVersion, - buildNumber = buildNumber, - releaseChannel = "beta", - eventType = eventType, - module = module, - eventName = name, - feature = feature, - message = message?.take(512), - errorCode = errorCode, - errorDomain = errorDomain, - tags = tags, - metrics = metrics, - occurredAt = isoNow(), - ) - - synchronized(queue) { - queue.add(event) - if (queue.size >= maxQueueSize) { - flush() - } - } - } - - fun trackScreen(screen: String) { - trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen)) - } - - // ── Flush ──────────────────────────────────────────────── - - fun flush() { - val batch: List - synchronized(queue) { - if (queue.isEmpty()) return - batch = queue.toList() - queue.clear() - } - - scope.launch { - val body = json.encodeToString(EventBatch(batch)) - client.fireAndForget( - "POST", "/api/telemetry/events", body, - extraHeaders = mapOf("X-Install-Token" to installId), - ) - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt deleted file mode 100644 index 3fd4d4b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ByteLystPlatform.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.bytelyst.platform - -import android.content.Context - -/** - * Unified entry point for the ByteLyst platform SDK. - * - * Creates and wires all platform services from a single config + context. - * Mirrors the Swift `ByteLystPlatform` API. - * - * Usage: - * ```kotlin - * val platform = ByteLystPlatform( - * context = applicationContext, - * config = BLPlatformConfig( - * productId = "chronomind", - * baseUrl = "https://api.chronomind.app", - * applicationId = "com.chronomind.app" - * ) - * ) - * - * platform.start() - * platform.telemetry.trackScreen("home") - * val isNew = platform.flags.isEnabled("new_feature") - * platform.stop() - * ``` - */ -class ByteLystPlatform( - context: Context, - val config: BLPlatformConfig, -) { - /** Secure storage (EncryptedSharedPreferences). */ - val secureStore: BLSecureStore = BLSecureStore(context, config.applicationId) - - /** HTTP client shared by services that need a token provider. */ - val client: BLPlatformClient = BLPlatformClient(config) { - secureStore.read("access_token") - } - - /** Auth client (login, register, refresh, MFA, etc.). */ - val auth: BLAuthClient = BLAuthClient(config, secureStore) - - /** Telemetry event tracking + batch flush. */ - val telemetry: BLTelemetryClient = BLTelemetryClient(config, context) - - /** Feature flag polling. */ - val flags: BLFeatureFlagClient = BLFeatureFlagClient(config) - - /** Kill switch checker. */ - val killSwitch: BLKillSwitchClient = BLKillSwitchClient(config) - - /** Local rotating audit log. */ - val auditLog: BLAuditLogger = BLAuditLogger(context, config) - - /** Whether [start] has been called. */ - var isStarted: Boolean = false - private set - - /** - * Start all services: telemetry flush timer, feature flag polling. - * Call on app launch / Activity.onCreate. - */ - fun start(userId: String? = null) { - if (isStarted) return - isStarted = true - telemetry.start() - flags.init(userId) - } - - /** - * Stop all services: flush telemetry, stop flag polling. - * Call on app background / Activity.onDestroy. - */ - fun stop() { - if (!isStarted) return - isStarted = false - telemetry.stop() - flags.stop() - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt deleted file mode 100644 index 7b5772b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/DeepLinkRouter.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.bytelyst.platform - -import android.net.Uri -import android.util.Log - -/** - * Deep Link Route data class - */ -data class DeepLinkRoute( - val screen: String, - val params: Map = emptyMap() -) - -/** - * Deep link handler type alias - */ -typealias DeepLinkHandler = (DeepLinkRoute) -> Unit - -/** - * Deep Link Router class - * Handles routing from push notification deep links to app screens - */ -class DeepLinkRouter { - private val handlers = mutableMapOf() - private var fallbackHandler: DeepLinkHandler? = null - - companion object { - private const val TAG = "DeepLinkRouter" - } - - /** - * Register a handler for a specific screen - */ - fun register(screen: String, handler: DeepLinkHandler) { - handlers[screen] = handler - } - - /** - * Set a fallback handler for unregistered screens - */ - fun setFallback(handler: DeepLinkHandler) { - fallbackHandler = handler - } - - /** - * Parse a deep link URL and extract route - */ - fun parseDeepLink(urlString: String): DeepLinkRoute? { - return try { - val uri = Uri.parse(urlString) - - // Handle app-specific URLs: myapp://screen/params - if (uri.scheme != "http" && uri.scheme != "https") { - val pathSegments = uri.pathSegments - val screen = pathSegments.firstOrNull() ?: "home" - - val params = mutableMapOf() - uri.queryParameterNames.forEach { key -> - uri.getQueryParameter(key)?.let { value -> - params[key] = value - } - } - - DeepLinkRoute(screen, params) - } - // Handle web URLs with deep link params - else if (uri.getQueryParameter("dl") != null) { - parseDeepLink(uri.getQueryParameter("dl")!!) - } - // Handle path-based routing: /screen/params - else { - val pathSegments = uri.pathSegments - if (pathSegments.isNotEmpty()) { - val screen = pathSegments[0] - - val params = mutableMapOf() - uri.queryParameterNames.forEach { key -> - uri.getQueryParameter(key)?.let { value -> - params[key] = value - } - } - - DeepLinkRoute(screen, params) - } else { - null - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse deep link: $urlString", e) - null - } - } - - /** - * Handle a deep link route - */ - fun handle(route: DeepLinkRoute): Boolean { - val handler = handlers[route.screen] - - return if (handler != null) { - handler(route) - true - } else if (fallbackHandler != null) { - fallbackHandler?.invoke(route) - true - } else { - Log.w(TAG, "No handler for screen: ${route.screen}") - false - } - } - - /** - * Process a deep link URL end-to-end - */ - fun process(urlString: String): Boolean { - val route = parseDeepLink(urlString) - return if (route != null) { - handle(route) - } else { - Log.w(TAG, "Failed to parse deep link: $urlString") - false - } - } -} - -/** - * Create a broadcast deep link URL - */ -fun createBroadcastDeepLink( - baseUrl: String, - screen: String, - params: Map = emptyMap(), - broadcastId: String? = null -): String { - val uriBuilder = Uri.parse(baseUrl).buildUpon() - .path("/$screen") - - params.forEach { (key, value) -> - uriBuilder.appendQueryParameter(key, value) - } - - broadcastId?.let { - uriBuilder.appendQueryParameter("broadcastId", it) - } - - return uriBuilder.build().toString() -} - -/** - * Common deep link screens - */ -object DeepLinkScreens { - // Broadcasts - const val BROADCAST = "broadcast" - const val ANNOUNCEMENTS = "announcements" - - // Surveys - const val SURVEY = "survey" - const val SURVEY_LIST = "surveys" - - // Product-specific - const val SETTINGS = "settings" - const val PROFILE = "profile" - const val UPGRADE = "upgrade" - const val SUPPORT = "support" - - // Fallback - const val HOME = "home" -} - -// Singleton instance for app-wide use -val deepLinkRouter = DeepLinkRouter() diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt deleted file mode 100644 index c8286cd..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/BreadcrumbTrail.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import java.text.SimpleDateFormat -import java.util.* - -/** - * Ring buffer for breadcrumbs with fixed max size - */ -class BreadcrumbTrail(private val maxSize: Int = 100) { - private val breadcrumbs = mutableListOf() - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** - * Add a breadcrumb to the trail - */ - @Synchronized - fun add(category: String, message: String, data: Map? = null) { - val breadcrumb = DiagnosticsBreadcrumb( - timestamp = dateFormat.format(Date()), - category = category, - message = message, - data = data - ) - - breadcrumbs.add(breadcrumb) - - // Evict oldest if over limit - if (breadcrumbs.size > maxSize) { - breadcrumbs.removeAt(0) - } - } - - /** - * Get all breadcrumbs (oldest first) - */ - @Synchronized - fun getAll(): List { - return breadcrumbs.toList() - } - - /** - * Get last N breadcrumbs - */ - @Synchronized - fun getLast(n: Int): List { - return breadcrumbs.takeLast(n) - } - - /** - * Get most recent breadcrumb - */ - @Synchronized - fun getMostRecent(): DiagnosticsBreadcrumb? { - return breadcrumbs.lastOrNull() - } - - /** - * Clear all breadcrumbs - */ - @Synchronized - fun clear() { - breadcrumbs.clear() - } - - /** - * Get current size - */ - @Synchronized - fun size(): Int { - return breadcrumbs.size - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt deleted file mode 100644 index 4da2ec8..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DeviceStateCollector.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import android.app.ActivityManager -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.BatteryManager -import android.os.Build -import android.os.StatFs - -/** - * Device state collector for Android - */ -object DeviceStateCollector { - - /** - * Collect current device state - */ - fun collect(context: Context): DiagnosticsDeviceState { - return DiagnosticsDeviceState( - memoryMB = getMemoryUsage(context), - batteryLevel = getBatteryLevel(context), - isCharging = getIsCharging(context), - storageMB = getStorageUsage(context), - networkType = getNetworkType(context), - isOnline = getIsOnline(context), - thermalState = null // Android doesn't expose thermal state easily - ) - } - - private fun getMemoryUsage(context: Context): Int? { - val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - ?: return null - - val runtime = Runtime.getRuntime() - val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) - - return usedMemory.toInt() - } - - private fun getBatteryLevel(context: Context): Float? { - val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - ?: return null - - val level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - - if (level == -1 || scale == -1) return null - - return level / scale.toFloat() - } - - private fun getIsCharging(context: Context): Boolean? { - val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - ?: return null - - val status = batteryIntent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) - return status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - } - - private fun getStorageUsage(context: Context): Int? { - val stat = StatFs(context.filesDir.path) - val blockSize = stat.blockSizeLong - val availableBlocks = stat.availableBlocksLong - val totalBlocks = stat.blockCountLong - - val usedBytes = (totalBlocks - availableBlocks) * blockSize - return (usedBytes / (1024 * 1024)).toInt() - } - - private fun getNetworkType(context: Context): String? { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return null - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork ?: return "offline" - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return "offline" - - when { - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" - else -> "unknown" - } - } else { - @Suppress("DEPRECATION") - val networkInfo = connectivityManager.activeNetworkInfo - when (networkInfo?.type) { - ConnectivityManager.TYPE_WIFI -> "wifi" - ConnectivityManager.TYPE_MOBILE -> "cellular" - ConnectivityManager.TYPE_ETHERNET -> "ethernet" - else -> if (networkInfo?.isConnected == true) "unknown" else "offline" - } - } - } - - private fun getIsOnline(context: Context): Boolean { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return true // Assume online if can't determine - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork - val capabilities = connectivityManager.getNetworkCapabilities(network) - capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } else { - @Suppress("DEPRECATION") - val networkInfo = connectivityManager.activeNetworkInfo - networkInfo?.isConnected == true - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt deleted file mode 100644 index 50c8b2d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsClient.kt +++ /dev/null @@ -1,535 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import android.content.Context -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* -import java.util.concurrent.TimeUnit - -/** - * Client state - */ -sealed class DiagnosticsClientState { - object Idle : DiagnosticsClientState() - data class Polling(val session: DiagnosticsSession?) : DiagnosticsClientState() - data class Active(val session: DiagnosticsSession) : DiagnosticsClientState() - data class Error(val exception: Throwable) : DiagnosticsClientState() -} - -/** - * Diagnostics client configuration - */ -data class DiagnosticsConfiguration( - val productId: String, - val userId: String? = null, - val anonymousInstallId: String, - val platform: String, - val channel: String, - val osFamily: String, - val appVersion: String, - val buildNumber: String, - val releaseChannel: String, - val serverUrl: String, - val pollIntervalMs: Long = 5000, - val maxBreadcrumbs: Int = 100, - val captureConsole: Boolean = true, - val captureErrors: Boolean = true, - val captureNetwork: Boolean = true, - val getAuthToken: (suspend () -> String)? = null -) - -/** - * Logger interface - */ -interface DiagnosticsLogger { - fun debug(message: String, metadata: Map? = null) - fun info(message: String, metadata: Map? = null) - fun warn(message: String, metadata: Map? = null) - fun error(message: String, metadata: Map? = null) -} - -/** - * No-op logger - */ -class NoOpDiagnosticsLogger : DiagnosticsLogger { - override fun debug(message: String, metadata: Map?) {} - override fun info(message: String, metadata: Map?) {} - override fun warn(message: String, metadata: Map?) {} - override fun error(message: String, metadata: Map?) {} -} - -/** - * Android Log-based logger - */ -class AndroidDiagnosticsLogger(private val tag: String = "ByteLystDiagnostics") : DiagnosticsLogger { - override fun debug(message: String, metadata: Map?) { - android.util.Log.d(tag, message) - } - override fun info(message: String, metadata: Map?) { - android.util.Log.i(tag, message) - } - override fun warn(message: String, metadata: Map?) { - android.util.Log.w(tag, message) - } - override fun error(message: String, metadata: Map?) { - android.util.Log.e(tag, message) - } -} - -/** - * Main diagnostics client - */ -class DiagnosticsClient private constructor( - private val context: Context, - private val config: DiagnosticsConfiguration, - private val logger: DiagnosticsLogger -) { - companion object { - @Volatile - private var instance: DiagnosticsClient? = null - - fun getInstance( - context: Context, - config: DiagnosticsConfiguration, - logger: DiagnosticsLogger = NoOpDiagnosticsLogger() - ): DiagnosticsClient { - return instance ?: synchronized(this) { - instance ?: DiagnosticsClient(context.applicationContext, config, logger).also { - instance = it - } - } - } - - fun reset() { - instance?.stop() - instance = null - } - } - - private val _state = MutableStateFlow(DiagnosticsClientState.Idle) - val state: StateFlow = _state.asStateFlow() - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val breadcrumbs = BreadcrumbTrail(maxSize = config.maxBreadcrumbs) - private val logBuffer = mutableListOf() - private val traceBuffer = mutableListOf() - private val networkBuffer = mutableListOf() - - private var pollJob: Job? = null - private var flushJob: Job? = null - private var networkInterceptor: NetworkInterceptor? = null - private var lastEtag: String? = null - - private val httpClient = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { ignoreUnknownKeys = true } - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - /** - * Start polling for active debug sessions - */ - fun start() { - if (_state.value != DiagnosticsClientState.Idle) { - logger.warn("[diagnostics] Already started") - return - } - - logger.info("[diagnostics] Starting diagnostics client") - _state.value = DiagnosticsClientState.Polling(null) - - // Initial poll - scope.launch { - pollForSession() - } - - // Start polling timer - pollJob = scope.launch { - while (isActive) { - delay(config.pollIntervalMs) - pollForSession() - } - } - - // Start auto-flush timer (every 30 seconds) - flushJob = scope.launch { - while (isActive) { - delay(30000) - flush() - } - } - - // Setup network capture if enabled - if (config.captureNetwork) { - setupNetworkCapture() - } - - breadcrumbs.add(category = "diagnostics", message = "Client started") - } - - /** - * Stop polling and cleanup - */ - fun stop() { - logger.info("[diagnostics] Stopping diagnostics client") - - pollJob?.cancel() - pollJob = null - - flushJob?.cancel() - flushJob = null - - networkInterceptor?.stop() - networkInterceptor = null - - // Final flush - scope.launch { - flush() - } - - _state.value = DiagnosticsClientState.Idle - breadcrumbs.add(category = "diagnostics", message = "Client stopped") - } - - /** - * Check if a debug session is currently active - */ - fun isSessionActive(): Boolean { - return _state.value is DiagnosticsClientState.Active - } - - /** - * Get current session if active - */ - fun getCurrentSession(): DiagnosticsSession? { - return when (val current = _state.value) { - is DiagnosticsClientState.Active -> current.session - is DiagnosticsClientState.Polling -> current.session - else -> null - } - } - - /** - * Record a log entry - */ - fun log( - level: DiagnosticsLogLevel, - message: String, - module: String = "unknown", - file: String? = null, - line: Int? = null, - function: String? = null, - context: Map = emptyMap(), - correlationId: String? = null - ) { - val entry = DiagnosticsLogEntry( - level = level, - message = message, - timestamp = dateFormat.format(Date()), - module = module, - file = file, - line = line, - function = function, - context = context, - correlationId = correlationId - ) - - synchronized(logBuffer) { - logBuffer.add(entry) - } - - breadcrumbs.add( - category = "log", - message = "[${level.name}] ${message.take(100)}", - data = mapOf("level" to level.name) - ) - - // Auto-flush on fatal - if (level == DiagnosticsLogLevel.FATAL) { - scope.launch { flush() } - } - } - - /** - * Record a trace span (auto-instrumented) - */ - suspend fun trace(name: String, operation: suspend () -> T): T { - val spanId = generateId() - val startTime = dateFormat.format(Date()) - - breadcrumbs.add( - category = "trace", - message = "Starting: $name", - data = mapOf("spanId" to spanId) - ) - - return try { - val result = operation() - val endTime = dateFormat.format(Date()) - val durationMs = calculateDuration(startTime, endTime) - - val span = DiagnosticsTraceSpan( - spanId = spanId, - name = name, - startTime = startTime, - endTime = endTime, - durationMs = durationMs, - status = DiagnosticsSpanStatus.OK - ) - - synchronized(traceBuffer) { - traceBuffer.add(span) - } - - breadcrumbs.add( - category = "trace", - message = "Completed: $name", - data = mapOf("spanId" to spanId, "durationMs" to durationMs.toString()) - ) - - result - } catch (e: Exception) { - val endTime = dateFormat.format(Date()) - val durationMs = calculateDuration(startTime, endTime) - - val span = DiagnosticsTraceSpan( - spanId = spanId, - name = name, - startTime = startTime, - endTime = endTime, - durationMs = durationMs, - status = DiagnosticsSpanStatus.ERROR, - statusMessage = e.message - ) - - synchronized(traceBuffer) { - traceBuffer.add(span) - } - - breadcrumbs.add( - category = "trace", - message = "Failed: $name", - data = mapOf("spanId" to spanId, "error" to (e.message ?: "Unknown")) - ) - - throw e - } - } - - /** - * Add a manual breadcrumb - */ - fun breadcrumb(category: String, message: String, data: Map? = null) { - breadcrumbs.add(category = category, message = message, data = data) - } - - /** - * Get all breadcrumbs - */ - fun getBreadcrumbs(): List { - return breadcrumbs.getAll() - } - - /** - * Collect and return device state - */ - fun collectDeviceState(): DiagnosticsDeviceState { - return DeviceStateCollector.collect(context) - } - - // Private methods - - private suspend fun pollForSession() { - try { - val url = "${config.serverUrl}/api/diagnostics/config" + - "?productId=${config.productId}" + - "&installId=${config.anonymousInstallId}" - - val requestBuilder = Request.Builder() - .url(url) - .header("Accept", "application/json") - - lastEtag?.let { etag -> - requestBuilder.header("If-None-Match", etag) - } - - config.getAuthToken?.let { getToken -> - try { - val token = getToken() - requestBuilder.header("Authorization", "Bearer $token") - } catch (e: Exception) { - logger.error("[diagnostics] Failed to get auth token", mapOf("error" to (e.message ?: "unknown"))) - } - } - - val request = requestBuilder.build() - - httpClient.newCall(request).execute().use { response -> - if (response.code == 304) { - // No change - return - } - - if (!response.isSuccessful) { - throw IOException("HTTP ${response.code}") - } - - // Store ETag - response.header("ETag")?.let { etag -> - lastEtag = etag - } - - val body = response.body?.string() - val session = body?.let { - try { - json.decodeFromString(it) - } catch (e: Exception) { - null - } - } - - // Update state - if (session != null && session.status == DiagnosticsSessionStatus.ACTIVE) { - if (_state.value !is DiagnosticsClientState.Active) { - logger.info("[diagnostics] Session activated", mapOf("sessionId" to session.id)) - breadcrumbs.add( - category = "diagnostics", - message = "Session activated", - data = mapOf("sessionId" to session.id) - ) - } - _state.value = DiagnosticsClientState.Active(session) - } else { - if (_state.value is DiagnosticsClientState.Active) { - logger.info("[diagnostics] Session ended") - breadcrumbs.add(category = "diagnostics", message = "Session ended") - } - _state.value = DiagnosticsClientState.Polling(null) - } - } - } catch (e: Exception) { - logger.error("[diagnostics] Failed to poll for session", mapOf("error" to (e.message ?: "unknown"))) - _state.value = DiagnosticsClientState.Error(e) - } - } - - private suspend fun flush() { - val session = getCurrentSession() - if (session == null) { - // No active session, clear buffers - synchronized(logBuffer) { logBuffer.clear() } - synchronized(traceBuffer) { traceBuffer.clear() } - synchronized(networkBuffer) { networkBuffer.clear() } - return - } - - // Build batch - val batch = DiagnosticsIngestBatch( - sessionId = session.id, - traces = synchronized(traceBuffer) { - if (traceBuffer.isEmpty()) null else traceBuffer.take(50).also { - repeat(it.size) { traceBuffer.removeAt(0) } - } - }, - logs = synchronized(logBuffer) { - if (logBuffer.isEmpty()) null else logBuffer.take(50).also { - repeat(it.size) { logBuffer.removeAt(0) } - } - }, - network = synchronized(networkBuffer) { - if (networkBuffer.isEmpty()) null else networkBuffer.take(50).also { - repeat(it.size) { networkBuffer.removeAt(0) } - } - }, - breadcrumbs = breadcrumbs.getAll().takeIf { it.isNotEmpty() }?.also { - breadcrumbs.clear() - } - ) - - // Skip if nothing to send - if (batch.traces == null && batch.logs == null && batch.network == null && batch.breadcrumbs == null) { - return - } - - try { - val url = "${config.serverUrl}/api/diagnostics/ingest" - - val requestBody = json.encodeToString(batch) - .toRequestBody("application/json".toMediaType()) - - val requestBuilder = Request.Builder() - .url(url) - .post(requestBody) - - config.getAuthToken?.let { getToken -> - try { - val token = getToken() - requestBuilder.header("Authorization", "Bearer $token") - } catch (e: Exception) { - logger.error("[diagnostics] Failed to get auth token for flush", mapOf("error" to (e.message ?: "unknown"))) - } - } - - val request = requestBuilder.build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IOException("HTTP ${response.code}") - } - - logger.debug( - "[diagnostics] Flushed batch", - mapOf( - "logs" to (batch.logs?.size ?: 0).toString(), - "traces" to (batch.traces?.size ?: 0).toString(), - "network" to (batch.network?.size ?: 0).toString() - ) - ) - } - } catch (e: Exception) { - logger.error("[diagnostics] Failed to flush batch", mapOf("error" to (e.message ?: "unknown"))) - - // Put items back in buffers for retry - synchronized(logBuffer) { batch.logs?.let { logBuffer.addAll(0, it) } } - synchronized(traceBuffer) { batch.traces?.let { traceBuffer.addAll(0, it) } } - synchronized(networkBuffer) { batch.network?.let { networkBuffer.addAll(0, it) } } - } - } - - private fun setupNetworkCapture() { - networkInterceptor = NetworkInterceptor { request -> - synchronized(networkBuffer) { - networkBuffer.add(request) - } - } - networkInterceptor?.start(httpClient) - breadcrumbs.add(category = "diagnostics", message = "Network capture enabled") - } - - private fun generateId(): String { - return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" - } - - private fun calculateDuration(startTime: String, endTime: String): Double { - return try { - val start = dateFormat.parse(startTime)?.time ?: 0 - val end = dateFormat.parse(endTime)?.time ?: 0 - (end - start).toDouble() - } catch (e: Exception) { - 0.0 - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt deleted file mode 100644 index 2e63fd1..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypes.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import kotlinx.serialization.Serializable - -/** - * Log severity levels (matches syslog/OpenTelemetry) - */ -enum class DiagnosticsLogLevel { - DEBUG, INFO, WARN, ERROR, FATAL -} - -/** - * Session status from the server - */ -enum class DiagnosticsSessionStatus { - PENDING, ACTIVE, PAUSED, COMPLETED, CANCELLED -} - -/** - * Collection level determines verbosity of captured data - */ -enum class DiagnosticsCollectionLevel { - STANDARD, DEBUG, TRACE -} - -/** - * Diagnostic session configuration from server - */ -@Serializable -data class DiagnosticsSession( - val id: String, - val productId: String, - val status: DiagnosticsSessionStatus, - val collectionLevel: DiagnosticsCollectionLevel, - val captureLogs: Boolean, - val captureNetwork: Boolean, - val captureScreenshots: Boolean, - val screenshotOnError: Boolean, - val maxDurationMinutes: Int, - val createdAt: String, - val expiresAt: String -) - -/** - * Span kind for OpenTelemetry compatibility - */ -enum class DiagnosticsSpanKind { - INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER -} - -/** - * Span status - */ -enum class DiagnosticsSpanStatus { - OK, ERROR, UNSET -} - -/** - * OpenTelemetry-compatible trace span - */ -@Serializable -data class DiagnosticsTraceSpan( - val spanId: String, - val parentId: String? = null, - val name: String, - val kind: DiagnosticsSpanKind? = null, - val startTime: String, - val endTime: String? = null, - val durationMs: Double? = null, - val attributes: Map = emptyMap(), - val status: DiagnosticsSpanStatus, - val statusMessage: String? = null -) - -/** - * Structured log entry - */ -@Serializable -data class DiagnosticsLogEntry( - val level: DiagnosticsLogLevel, - val message: String, - val timestamp: String, - val module: String, - val file: String? = null, - val line: Int? = null, - val function: String? = null, - val context: Map = emptyMap(), - val correlationId: String? = null -) - -/** - * Breadcrumb for timeline navigation - */ -@Serializable -data class DiagnosticsBreadcrumb( - val timestamp: String, - val category: String, - val message: String, - val data: Map? = null -) - -/** - * Network request/response capture - */ -@Serializable -data class DiagnosticsNetworkRequest( - val id: String, - val url: String, - val method: String, - val requestHeaders: Map = emptyMap(), - val requestBody: String? = null, - val status: Int? = null, - val responseHeaders: Map? = null, - val responseBody: String? = null, - val startTime: String, - val endTime: String? = null, - val durationMs: Double? = null, - val error: String? = null -) - -/** - * Device state snapshot - */ -@Serializable -data class DiagnosticsDeviceState( - val memoryMB: Int? = null, - val batteryLevel: Float? = null, - val isCharging: Boolean? = null, - val storageMB: Int? = null, - val networkType: String? = null, - val isOnline: Boolean, - val thermalState: DiagnosticsThermalState? = null -) - -/** - * Thermal state - */ -enum class DiagnosticsThermalState { - NOMINAL, FAIR, SERIOUS, CRITICAL -} - -/** - * Ingest batch for sending to server - */ -@Serializable -data class DiagnosticsIngestBatch( - val sessionId: String, - val traces: List? = null, - val logs: List? = null, - val breadcrumbs: List? = null, - val network: List? = null -) diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt deleted file mode 100644 index 3b88b30..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/diagnostics/NetworkInterceptor.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import okhttp3.* -import okhttp3.Interceptor.Chain -import okio.Buffer -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* - -/** - * Network interceptor for OkHttp to capture HTTP requests/responses - */ -class NetworkInterceptor( - private val onRequest: (DiagnosticsNetworkRequest) -> Unit -) : Interceptor { - private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - private var isActive = false - private lateinit var httpClient: OkHttpClient - - fun start(client: OkHttpClient) { - this.httpClient = client - isActive = true - } - - fun stop() { - isActive = false - } - - override fun intercept(chain: Chain): Response { - if (!isActive) { - return chain.proceed(chain.request()) - } - - val request = chain.request() - val requestId = generateId() - val startTime = System.currentTimeMillis() - - // Capture request details - val requestHeaders = mutableMapOf() - for (i in 0 until request.headers.size) { - val name = request.headers.name(i) - val value = request.headers.value(i) - requestHeaders[name] = sanitizeHeader(value, name) - } - - val requestBody = request.body?.let { body -> - val buffer = Buffer() - try { - body.writeTo(buffer) - buffer.readUtf8() - } catch (e: Exception) { - null - } - } - - // Proceed with request - val response: Response - try { - response = chain.proceed(request) - } catch (e: Exception) { - // Capture failed request - val networkRequest = DiagnosticsNetworkRequest( - id = requestId, - url = request.url.toString().take(2048), - method = request.method, - requestHeaders = requestHeaders, - requestBody = requestBody?.take(100 * 1024), // Limit to 100KB - startTime = dateFormat.format(Date(startTime)), - endTime = dateFormat.format(Date()), - durationMs = (System.currentTimeMillis() - startTime).toDouble(), - error = e.message - ) - onRequest(networkRequest) - throw e - } - - // Capture response - val endTime = System.currentTimeMillis() - val responseHeaders = mutableMapOf() - for (i in 0 until response.headers.size) { - val name = response.headers.name(i) - val value = response.headers.value(i) - responseHeaders[name] = sanitizeHeader(value, name) - } - - val networkRequest = DiagnosticsNetworkRequest( - id = requestId, - url = request.url.toString().take(2048), - method = request.method, - requestHeaders = requestHeaders, - requestBody = requestBody?.take(100 * 1024), - status = response.code, - responseHeaders = responseHeaders, - responseBody = null, // Don't capture response body (too large) - startTime = dateFormat.format(Date(startTime)), - endTime = dateFormat.format(Date(endTime)), - durationMs = (endTime - startTime).toDouble(), - error = null - ) - - onRequest(networkRequest) - - return response - } - - private fun sanitizeHeader(value: String, key: String): String { - val sensitivePatterns = listOf("authorization", "cookie", "token", "api-key") - val lowerKey = key.lowercase() - - for (pattern in sensitivePatterns) { - if (lowerKey.contains(pattern)) { - return "[REDACTED]" - } - } - return value - } - - private fun generateId(): String { - return "${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(7)}" - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt deleted file mode 100644 index e9538a7..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BLAuthUI.kt +++ /dev/null @@ -1,664 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.bytelyst.platform.BLAuthClient -import kotlinx.coroutines.launch - -// ── Auth UI Configuration ──────────────────────────────────── - -/** OAuth providers supported by BLAuthUI. */ -enum class BLAuthProvider { GOOGLE, MICROSOFT, APPLE } - -/** - * Configuration passed to BLAuthUI screens. - * Host product provides its theme colors and enabled providers. - */ -data class BLAuthUIConfig( - val productName: String = "ByteLyst", - val enabledProviders: List = listOf(BLAuthProvider.GOOGLE, BLAuthProvider.APPLE), -) - -// ── BLLoginScreen ──────────────────────────────────────────── - -/** - * Full login screen with email/password + social buttons + passkey option. - * Host product wraps this in its MaterialTheme for consistent theming. - * - * @param config UI configuration (product name, enabled providers). - * @param onLogin Called with (email, password) when user taps Sign In. - * @param onSocialLogin Called with provider when user taps a social button. - * @param onPasskeyLogin Called when user taps "Use Passkey" (null to hide). - * @param onForgotPassword Called when user taps "Forgot Password?" (null to hide). - * @param onCreateAccount Called when user taps "Create Account" (null to hide). - */ -@Composable -fun BLLoginScreen( - config: BLAuthUIConfig = BLAuthUIConfig(), - onLogin: suspend (String, String) -> Unit, - onSocialLogin: (BLAuthProvider) -> Unit, - onPasskeyLogin: (() -> Unit)? = null, - onForgotPassword: (() -> Unit)? = null, - onCreateAccount: (() -> Unit)? = null, -) { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(48.dp)) - - // Header - Text( - text = "Sign in to ${config.productName}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "Welcome back", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(32.dp)) - - // Social Buttons - if (config.enabledProviders.isNotEmpty()) { - config.enabledProviders.forEach { provider -> - SocialButton(provider = provider, onClick = { onSocialLogin(provider) }) - Spacer(Modifier.height(12.dp)) - } - DividerRow() - Spacer(Modifier.height(16.dp)) - } - - // Email / Password - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Email") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - - // Error - errorMessage?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(8.dp)) - } - - // Sign In Button - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - onLogin(email, password) - } catch (e: BLAuthClient.MfaRequiredException) { - // MFA required — handled by caller - } catch (e: Exception) { - errorMessage = e.message ?: "Login failed" - } - isLoading = false - } - }, - enabled = email.isNotBlank() && password.isNotBlank() && !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Sign In") - } - Spacer(Modifier.height(12.dp)) - - // Passkey - if (onPasskeyLogin != null) { - OutlinedButton( - onClick = onPasskeyLogin, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - Icon(Icons.Default.Key, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text("Sign in with Passkey") - } - Spacer(Modifier.height(16.dp)) - } - - // Forgot Password / Create Account - if (onForgotPassword != null) { - TextButton(onClick = onForgotPassword) { - Text("Forgot Password?") - } - } - - if (onCreateAccount != null) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Don't have an account?", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TextButton(onClick = onCreateAccount) { - Text("Create Account") - } - } - } - - Spacer(Modifier.height(32.dp)) - } -} - -// ── BLMfaChallengeScreen ───────────────────────────────────── - -/** - * 6-digit TOTP code entry with recovery code fallback. - * - * @param challenge The MFA challenge from login response. - * @param onVerify Called with (challengeToken, code, method) on submit. - * @param onCancel Called when user cancels. - */ -@Composable -fun BLMfaChallengeScreen( - challenge: BLAuthClient.MfaChallenge, - onVerify: suspend (String, String, String) -> Unit, - onCancel: (() -> Unit)? = null, -) { - var code by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var useRecovery by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - - Text( - text = "Two-Factor Authentication", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(8.dp)) - - Text( - text = if (useRecovery) "Enter a recovery code" - else "Enter the 6-digit code from your authenticator app", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - - OutlinedTextField( - value = code, - onValueChange = { code = it }, - label = { Text(if (useRecovery) "Recovery Code" else "Code") }, - keyboardOptions = KeyboardOptions( - keyboardType = if (useRecovery) KeyboardType.Text else KeyboardType.Number, - ), - singleLine = true, - textStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - ), - modifier = Modifier.width(200.dp), - ) - Spacer(Modifier.height(16.dp)) - - errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - val method = if (useRecovery) "recovery" else "totp" - try { - onVerify(challenge.mfaChallenge, code, method) - } catch (e: Exception) { - errorMessage = e.message ?: "Verification failed" - code = "" - } - isLoading = false - } - }, - enabled = code.isNotBlank() && !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Verify") - } - Spacer(Modifier.height(16.dp)) - - TextButton(onClick = { - useRecovery = !useRecovery - code = "" - errorMessage = null - }) { - Text(if (useRecovery) "Use authenticator code" else "Use recovery code") - } - - if (onCancel != null) { - TextButton(onClick = onCancel) { - Text("Cancel", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -// ── BLPasskeyScreen ────────────────────────────────────────── - -/** - * Passkey prompt with biometric hint text. - * Triggers Android Credential Manager for passkey authentication. - * - * @param onAuthenticate Called when user taps Continue (triggers passkey flow). - * @param onCancel Called when user taps "Use another method". - */ -@Composable -fun BLPasskeyScreen( - onAuthenticate: suspend () -> Unit, - onCancel: (() -> Unit)? = null, -) { - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Default.Key, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - - Text( - text = "Sign in with Passkey", - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(8.dp)) - - Text( - text = "Use your fingerprint, face, or screen lock to sign in", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(24.dp)) - - errorMessage?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - onAuthenticate() - } catch (e: Exception) { - errorMessage = e.message ?: "Passkey authentication failed" - } - isLoading = false - } - }, - enabled = !isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Icon(Icons.Default.Fingerprint, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text("Continue") - } - Spacer(Modifier.height(16.dp)) - - if (onCancel != null) { - TextButton(onClick = onCancel) { - Text("Use another method") - } - } - } -} - -// ── BLDeviceListScreen ─────────────────────────────────────── - -/** - * Device management screen — list trusted/remembered devices, revoke trust. - * - * @param devices List of devices from BLAuthClient.listDevices(). - * @param onRevokeDevice Called with device ID when user revokes a device. - * @param onRevokeAll Called when user revokes all devices. - * @param isLoading Whether data is loading. - */ -@Composable -fun BLDeviceListScreen( - devices: List, - onRevokeDevice: (String) -> Unit, - onRevokeAll: (() -> Unit)? = null, - isLoading: Boolean = false, -) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Your Devices", - style = MaterialTheme.typography.titleMedium, - ) - if (onRevokeAll != null && devices.isNotEmpty()) { - TextButton(onClick = onRevokeAll) { - Text("Revoke All", color = MaterialTheme.colorScheme.error) - } - } - } - Spacer(Modifier.height(16.dp)) - - if (isLoading) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (devices.isEmpty()) { - Text( - text = "No devices found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - devices.forEach { device -> - DeviceCard(device = device, onRevoke = { onRevokeDevice(device.fingerprint) }) - Spacer(Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun DeviceCard( - device: BLAuthClient.Device, - onRevoke: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = when (device.platform) { - "ios" -> Icons.Default.PhoneIphone - "android" -> Icons.Default.PhoneAndroid - "macos", "windows", "linux" -> Icons.Default.Laptop - else -> Icons.Default.Devices - }, - contentDescription = device.platform, - modifier = Modifier.size(32.dp), - tint = when (device.trustLevel) { - "trusted" -> MaterialTheme.colorScheme.primary - "remembered" -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - Spacer(Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = device.name, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = "${device.trustLevel} · ${device.platform}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (device.trustLevel == "trusted" || device.trustLevel == "remembered") { - IconButton(onClick = onRevoke) { - Icon( - Icons.Default.Close, - contentDescription = "Revoke", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } - } -} - -// ── BLStepUpDialog ─────────────────────────────────────────── - -/** - * Re-authentication dialog for sensitive operations. - * Supports password re-entry. - * - * @param reason Description of why re-auth is needed. - * @param onStepUp Called with (method, credential) — returns step-up token. - * @param onComplete Called with the step-up token on success. - * @param onDismiss Called when user dismisses the dialog. - */ -@Composable -fun BLStepUpDialog( - reason: String = "This action requires re-authentication", - onStepUp: suspend (String, String) -> String, - onComplete: (String) -> Unit, - onDismiss: () -> Unit, -) { - var password by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - AlertDialog( - onDismissRequest = onDismiss, - icon = { - Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(32.dp)) - }, - title = { Text("Confirm Your Identity") }, - text = { - Column { - Text( - text = reason, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - errorMessage?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - scope.launch { - isLoading = true - errorMessage = null - try { - val token = onStepUp("password", password) - onComplete(token) - onDismiss() - } catch (e: Exception) { - errorMessage = e.message ?: "Verification failed" - } - isLoading = false - } - }, - enabled = password.isNotBlank() && !isLoading, - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - } - Text("Confirm") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) -} - -// ── Shared Components ──────────────────────────────────────── - -@Composable -private fun SocialButton( - provider: BLAuthProvider, - onClick: () -> Unit, -) { - OutlinedButton( - onClick = onClick, - modifier = Modifier.fillMaxWidth().height(52.dp), - shape = RoundedCornerShape(10.dp), - ) { - Icon( - imageVector = when (provider) { - BLAuthProvider.GOOGLE -> Icons.Default.Public - BLAuthProvider.MICROSOFT -> Icons.Default.Business - BLAuthProvider.APPLE -> Icons.Default.Star - }, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Continue with ${ - when (provider) { - BLAuthProvider.GOOGLE -> "Google" - BLAuthProvider.MICROSOFT -> "Microsoft" - BLAuthProvider.APPLE -> "Apple" - } - }", - ) - } -} - -@Composable -private fun DividerRow() { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - HorizontalDivider(modifier = Modifier.weight(1f)) - Text( - text = "or", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - HorizontalDivider(modifier = Modifier.weight(1f)) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt deleted file mode 100644 index a69ff9e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/BroadcastUI.kt +++ /dev/null @@ -1,330 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import coil.compose.AsyncImage -import com.bytelyst.platform.* -import kotlinx.coroutines.launch - -/** - * In-App Message Banner — Jetpack Compose component for top/bottom banner display. - * Part of ByteLystPlatformSDK. - */ -@Composable -fun InAppMessageBanner( - client: BLBroadcastClient, - position: BannerPosition = BannerPosition.TOP, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - var messages by remember { mutableStateOf>(emptyList()) } - var unreadCount by remember { mutableIntStateOf(0) } - - LaunchedEffect(client) { - // Initial load - val result = client.getMessages() - result.onSuccess { list -> - messages = list - unreadCount = messages.count { it.status == "UNREAD" } - } - - // Start polling - client.startPolling(60000L) { updatedMessages -> - messages = updatedMessages - unreadCount = updatedMessages.count { it.status == "UNREAD" } - } - } - - val bannerMessages = messages.filter { - it.status == "UNREAD" && (it.style == "BANNER" || it.style == "TOAST") - } - - if (bannerMessages.isEmpty()) return - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding( - top = if (position == BannerPosition.TOP) 16.dp else 0.dp, - bottom = if (position == BannerPosition.BOTTOM) 16.dp else 0.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - bannerMessages.forEach { message -> - BannerCard( - message = message, - onDismiss = { - scope.launch { - client.markDismissed(message.id) - messages = messages.filter { it.id != message.id } - } - }, - onTap = { - scope.launch { - client.trackClick(message.id) - message.ctaUrl?.let { url -> - // Open URL - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - context.startActivity(intent) - } - client.markRead(message.id) - messages = messages.map { - if (it.id == message.id) it.copy(status = "READ") - else it - } - } - } - ) - } - } -} - -enum class BannerPosition { - TOP, BOTTOM -} - -@Composable -private fun BannerCard( - message: InAppMessage, - onDismiss: () -> Unit, - onTap: () -> Unit -) { - val backgroundColor = when (message.priority.uppercase()) { - "URGENT" -> MaterialTheme.colorScheme.errorContainer - "HIGH" -> Color(0xFFFFF3E0) // Orange-ish - else -> MaterialTheme.colorScheme.surface - } - - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(4.dp, RoundedCornerShape(12.dp)) - .clickable { onTap() }, - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = backgroundColor) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - if (message.imageUrl != null) { - AsyncImage( - model = message.imageUrl, - contentDescription = null, - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop - ) - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = message.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - message.body?.let { body -> - Text( - text = body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - if (message.ctaText != null) { - Text( - text = "Tap to open →", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - - if (message.dismissible) { - IconButton( - onClick = onDismiss, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Dismiss", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } -} - -/** - * Broadcast Modal — Jetpack Compose component for modal/fullscreen broadcast display. - */ -@Composable -fun BroadcastModal( - client: BLBroadcastClient, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - var currentMessage by remember { mutableStateOf(null) } - var showDialog by remember { mutableStateOf(false) } - - LaunchedEffect(client) { - // Start polling for modal messages - client.startPolling(30000L) { messages -> - val modalMessages = messages.filter { - it.status == "UNREAD" && (it.style == "MODAL" || it.style == "FULLSCREEN") - } - if (modalMessages.isNotEmpty() && currentMessage == null) { - currentMessage = modalMessages.first() - showDialog = true - } - } - } - - if (showDialog && currentMessage != null) { - val message = currentMessage!! - - Dialog( - onDismissRequest = { - if (message.dismissible) { - scope.launch { - client.markDismissed(message.id) - } - showDialog = false - currentMessage = null - } - } - ) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Header with image - if (message.imageUrl != null) { - AsyncImage( - model = message.imageUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clip(RoundedCornerShape(12.dp)), - contentScale = ContentScale.Crop - ) - } - - Text( - text = message.title, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.fillMaxWidth() - ) - - message.body?.let { body -> - Text( - text = body, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - } - - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - if (message.dismissible) { - TextButton( - onClick = { - scope.launch { - client.markDismissed(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text("Dismiss") - } - } - - if (message.ctaText != null) { - Button( - onClick = { - scope.launch { - client.trackClick(message.id) - message.ctaUrl?.let { url -> - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - context.startActivity(intent) - } - client.markRead(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text(message.ctaText) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - } - } else { - Button( - onClick = { - scope.launch { - client.markRead(message.id) - } - showDialog = false - currentMessage = null - } - ) { - Text("Got it") - } - } - } - } - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt b/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt deleted file mode 100644 index 9fdacab..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/ui/SurveyUI.kt +++ /dev/null @@ -1,775 +0,0 @@ -package com.bytelyst.platform.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.bytelyst.platform.* -import com.bytelyst.platform.BLSurveyClient.ActiveSurvey -import com.bytelyst.platform.BLSurveyClient.Question -import com.bytelyst.platform.BLSurveyClient.QuestionOption -import com.bytelyst.platform.BLSurveyClient.SurveyAnswer -import com.bytelyst.platform.BLSurveyClient.SurveyIncentive -import com.bytelyst.platform.BLSurveyClient.QuestionType -import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive - -/** - * Survey Modal — Jetpack Compose component for displaying and completing surveys. - * Part of ByteLystPlatformSDK. - */ -@Composable -fun SurveyModal( - client: BLSurveyClient, - modifier: Modifier = Modifier -) { - val scope = rememberCoroutineScope() - var survey by remember { mutableStateOf(null) } - var currentQuestionIndex by remember { mutableIntStateOf(0) } - var answers by remember { mutableStateOf>(emptyMap()) } - var isComplete by remember { mutableStateOf(false) } - var showCompletion by remember { mutableStateOf(false) } - var showDialog by remember { mutableStateOf(false) } - - // Question state - var selectedOption by remember { mutableStateOf(null) } - var selectedOptions by remember { mutableStateOf>(emptySet()) } - var ratingValue by remember { mutableIntStateOf(0) } - var textAnswer by remember { mutableStateOf("") } - var rankingOrder by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(client) { - // Check for active survey - val result = client.getActiveSurvey() - result.onSuccess { response -> - response?.let { - survey = it - showDialog = true - } - } - - // Start polling - client.startPolling(60000L) { newSurvey -> - if (newSurvey != null && survey == null) { - survey = newSurvey - showDialog = true - } - } - } - - fun doResetSurvey() { - survey = null - currentQuestionIndex = 0 - answers = emptyMap() - isComplete = false - showCompletion = false - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - } - - if (showDialog) { - when { - showCompletion -> { - CompletionDialog( - survey = survey, - onDismiss = { - scope.launch { - survey?.let { client.dismissSurvey(it.id) } - } - showDialog = false - doResetSurvey() - } - ) - } - survey != null && currentQuestionIndex < survey!!.questions.size -> { - val question = survey!!.questions[currentQuestionIndex] - QuestionDialog( - survey = survey!!, - question = question, - questionIndex = currentQuestionIndex, - totalQuestions = survey!!.questions.size, - selectedOption = selectedOption, - selectedOptions = selectedOptions, - ratingValue = ratingValue, - textAnswer = textAnswer, - rankingOrder = rankingOrder, - onSelectedOptionChange = { selectedOption = it }, - onSelectedOptionsChange = { selectedOptions = it }, - onRatingChange = { ratingValue = it }, - onTextChange = { textAnswer = it }, - onRankingChange = { rankingOrder = it }, - onSubmit = { - scope.launch { - submitAnswer( - client = client, - survey = survey!!, - question = question, - selectedOption = selectedOption, - selectedOptions = selectedOptions, - ratingValue = ratingValue, - textAnswer = textAnswer, - rankingOrder = rankingOrder, - onSuccess = { newIndex, newAnswers -> - currentQuestionIndex = newIndex - answers = newAnswers - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - - if (currentQuestionIndex >= survey!!.questions.size) { - scope.launch { - completeSurvey(client, survey!!.id) { success, _ -> - if (success) { - isComplete = true - showCompletion = true - } - } - } - } - } - ) - } - }, - onSkip = { - if (!question.required) { - scope.launch { - skipQuestion(client, survey!!, question.id) { newIndex -> - currentQuestionIndex = newIndex - resetQuestionState( - onSelectedOption = { selectedOption = it }, - onSelectedOptions = { selectedOptions = it }, - onRating = { ratingValue = it }, - onText = { textAnswer = it }, - onRanking = { rankingOrder = it } - ) - - if (currentQuestionIndex >= survey!!.questions.size) { - scope.launch { - completeSurvey(client, survey!!.id) { success, _ -> - if (success) { - isComplete = true - showCompletion = true - } - } - } - } - } - } - } - }, - onDismiss = { - scope.launch { - survey?.let { client.dismissSurvey(it.id) } - } - showDialog = false - doResetSurvey() - } - ) - } - } - } -} - -private suspend fun submitAnswer( - client: BLSurveyClient, - survey: ActiveSurvey, - question: Question, - selectedOption: String?, - selectedOptions: Set, - ratingValue: Int, - textAnswer: String, - rankingOrder: List, - onSuccess: (Int, Map) -> Unit -) { - val qType = question.type.toQuestionType() - val answer: SurveyAnswer = when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - selectedOption?.let { - SurveyAnswer(type = "single_choice", value = JsonObject(mapOf("value" to JsonPrimitive(it)))) - } ?: return - } - QuestionType.MULTIPLE_CHOICE -> { - SurveyAnswer(type = "multiple_choice", value = JsonObject(mapOf("values" to JsonArray(selectedOptions.map { JsonPrimitive(it) })))) - } - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { - SurveyAnswer(type = "rating", value = JsonObject(mapOf("value" to JsonPrimitive(ratingValue)))) - } - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { - SurveyAnswer(type = "text", value = JsonObject(mapOf("value" to JsonPrimitive(textAnswer)))) - } - QuestionType.RANKING -> { - SurveyAnswer(type = "ranking", value = JsonObject(mapOf("order" to JsonArray(rankingOrder.map { JsonPrimitive(it) })))) - } - null -> return - } - - val result = client.submitAnswer(survey.id, question.id, answer) - result.onSuccess { response -> - onSuccess(response.currentQuestionIndex, response.answers) - } -} - -private suspend fun skipQuestion( - client: BLSurveyClient, - survey: ActiveSurvey, - questionId: String, - onSuccess: (Int) -> Unit -) { - val answer = SurveyAnswer(type = "skipped", value = JsonObject(emptyMap())) - val result = client.submitAnswer(survey.id, questionId, answer) - result.onSuccess { response -> - onSuccess(response.currentQuestionIndex) - } -} - -private suspend fun completeSurvey( - client: BLSurveyClient, - surveyId: String, - onComplete: (Boolean, Boolean) -> Unit -) { - val result = client.completeSurvey(surveyId) - result.onSuccess { completion -> - onComplete(completion.success, completion.incentiveClaimed) - } -} - -private fun resetQuestionState( - onSelectedOption: (String?) -> Unit, - onSelectedOptions: (Set) -> Unit, - onRating: (Int) -> Unit, - onText: (String) -> Unit, - onRanking: (List) -> Unit -) { - onSelectedOption(null) - onSelectedOptions(emptySet()) - onRating(0) - onText("") - onRanking(emptyList()) -} - -@Composable -private fun QuestionDialog( - survey: ActiveSurvey, - question: Question, - questionIndex: Int, - totalQuestions: Int, - selectedOption: String?, - selectedOptions: Set, - ratingValue: Int, - textAnswer: String, - rankingOrder: List, - onSelectedOptionChange: (String?) -> Unit, - onSelectedOptionsChange: (Set) -> Unit, - onRatingChange: (Int) -> Unit, - onTextChange: (String) -> Unit, - onRankingChange: (List) -> Unit, - onSubmit: () -> Unit, - onSkip: () -> Unit, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Progress - LinearProgressIndicator( - progress = { (questionIndex + 1) / totalQuestions.toFloat() }, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = "Question ${questionIndex + 1} of $totalQuestions", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - // Question text - Text( - text = question.text, - style = MaterialTheme.typography.titleMedium - ) - question.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (question.required) { - Text( - text = "Required", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - - // Question input - val qType = question.type.toQuestionType() - when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> { - SingleChoiceInput( - options = question.options ?: emptyList(), - selected = selectedOption, - onSelect = onSelectedOptionChange - ) - } - QuestionType.MULTIPLE_CHOICE -> { - MultipleChoiceInput( - options = question.options ?: emptyList(), - selected = selectedOptions, - onSelect = onSelectedOptionsChange - ) - } - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> { - RatingInput( - minValue = question.minValue ?: if (qType == QuestionType.NPS) 0 else 1, - maxValue = question.maxValue ?: if (qType == QuestionType.NPS) 10 else 5, - rating = ratingValue, - onRatingChange = onRatingChange - ) - } - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> { - TextAnswerInput( - text = textAnswer, - isLong = qType == QuestionType.TEXT_LONG, - maxLength = question.maxLength, - onTextChange = onTextChange - ) - } - QuestionType.RANKING -> { - RankingInput( - options = question.options ?: emptyList(), - order = rankingOrder, - onOrderChange = onRankingChange - ) - } - null -> { /* unknown type */ } - } - - // Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (!question.required) { - TextButton(onClick = onSkip) { - Text("Skip") - } - } else { - Spacer(modifier = Modifier.width(1.dp)) - } - - val canSubmit = when (qType) { - QuestionType.SINGLE_CHOICE, QuestionType.DROPDOWN -> selectedOption != null - QuestionType.MULTIPLE_CHOICE -> selectedOptions.isNotEmpty() - QuestionType.RATING, QuestionType.SCALE, QuestionType.NPS -> ratingValue > 0 - QuestionType.TEXT_SHORT, QuestionType.TEXT_LONG -> textAnswer.isNotBlank() - QuestionType.RANKING -> rankingOrder.size == (question.options?.size ?: 0) - null -> false - } - - val isLast = questionIndex == totalQuestions - 1 - Button( - onClick = onSubmit, - enabled = canSubmit - ) { - Text(if (isLast) "Complete" else "Next") - } - } - } - } - } -} - -@Composable -private fun SingleChoiceInput( - options: List, - selected: String?, - onSelect: (String?) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - if (selected == option.id) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .clickable { onSelect(option.id) } - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = if (selected == option.id) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, - contentDescription = null, - tint = if (selected == option.id) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Composable -private fun MultipleChoiceInput( - options: List, - selected: Set, - onSelect: (Set) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - val isSelected = selected.contains(option.id) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .clickable { - val newSet = if (isSelected) selected - option.id else selected + option.id - onSelect(newSet) - } - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = option.emoji ?: "") - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - Checkbox( - checked = isSelected, - onCheckedChange = { - val newSet = if (it) selected + option.id else selected - option.id - onSelect(newSet) - } - ) - } - } - } -} - -@Composable -private fun RatingInput( - minValue: Int, - maxValue: Int, - rating: Int, - onRatingChange: (Int) -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - (minValue..maxValue).forEach { value -> - Button( - onClick = { onRatingChange(value) }, - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = if (rating == value) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (rating == value) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier.size(44.dp) - ) { - Text("$value") - } - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (maxValue == 10) "Not likely" else "Low", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = if (maxValue == 10) "Very likely" else "High", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun TextAnswerInput( - text: String, - isLong: Boolean, - maxLength: Int?, - onTextChange: (String) -> Unit -) { - Column { - if (isLong) { - OutlinedTextField( - value = text, - onValueChange = { newText -> - maxLength?.let { - if (newText.length <= it) onTextChange(newText) - } ?: onTextChange(newText) - }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - maxLines = 6, - keyboardOptions = KeyboardOptions.Default - ) - } else { - OutlinedTextField( - value = text, - onValueChange = { newText -> - maxLength?.let { - if (newText.length <= it) onTextChange(newText) - } ?: onTextChange(newText) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions.Default - ) - } - maxLength?.let { - Text( - text = "${text.length}/$it", - style = MaterialTheme.typography.labelSmall, - color = if (text.length > it) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.End) - ) - } - } -} - -@Composable -private fun RankingInput( - options: List, - order: List, - onOrderChange: (List) -> Unit -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { option -> - val rank = order.indexOf(option.id).let { if (it >= 0) it + 1 else null } - val isRanked = rank != null - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Rank badge - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background( - if (isRanked) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surface - ), - contentAlignment = Alignment.Center - ) { - Text( - text = rank?.toString() ?: "-", - style = MaterialTheme.typography.labelSmall, - color = if (isRanked) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Text( - text = option.text, - modifier = Modifier.weight(1f) - ) - - // Controls - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - onClick = { - val idx = order.indexOf(option.id) - if (idx > 0) { - val newOrder = order.toMutableList() - newOrder.swap(idx, idx - 1) - onOrderChange(newOrder) - } - }, - enabled = isRanked && rank!! > 1, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.ArrowUpward, null, modifier = Modifier.size(16.dp)) - } - - IconButton( - onClick = { - val idx = order.indexOf(option.id) - if (idx < order.size - 1) { - val newOrder = order.toMutableList() - newOrder.swap(idx, idx + 1) - onOrderChange(newOrder) - } - }, - enabled = isRanked && rank!! < order.size, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.ArrowDownward, null, modifier = Modifier.size(16.dp)) - } - - IconButton( - onClick = { - if (!isRanked) { - onOrderChange(order + option.id) - } - }, - enabled = !isRanked, - modifier = Modifier.size(32.dp) - ) { - Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp)) - } - } - } - } - } -} - -private fun String.toQuestionType(): QuestionType? = - QuestionType.entries.firstOrNull { it.name.equals(this, ignoreCase = true) } - -private fun MutableList.swap(i: Int, j: Int) { - val temp = this[i] - this[i] = this[j] - this[j] = temp -} - -@Composable -private fun CompletionDialog( - survey: ActiveSurvey?, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = Color(0xFF4CAF50) - ) - - Text( - text = "Thank You!", - style = MaterialTheme.typography.headlineSmall - ) - - Text( - text = "Your feedback helps us improve.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - survey?.incentive?.let { incentive: SurveyIncentive -> - Card( - colors = CardDefaults.cardColors( - containerColor = Color(0xFFE8F5E9) - ) - ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = Color(0xFF4CAF50) - ) - Text( - text = "You've earned ${incentive.amount} ${if (incentive.type == "pro_days") "Pro Days" else "Credits"}!", - style = MaterialTheme.typography.titleSmall, - color = Color(0xFF2E7D32) - ) - } - } - } - - Button( - onClick = onDismiss, - modifier = Modifier.fillMaxWidth() - ) { - Text("Close") - } - } - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt deleted file mode 100644 index 82c1722..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLAuthClientSmartAuthTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.builtins.MapSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.json.Json -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -/** - * SmartAuth v2 tests for Kotlin BLAuthClient. - * Uses MockWebServer to verify social login, MFA, and type serialization. - */ -class BLAuthClientSmartAuthTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - @BeforeEach - fun setUp() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - platform = "android", - channel = "test", - applicationId = "com.test.smartauth", - ) - } - - @AfterEach - fun tearDown() { - server.shutdown() - } - - // ── Test: Social Login (Google) calls correct endpoint ─── - - @Test - fun `loginWithGoogle sends idToken to correct endpoint`() = runTest { - // Arrange: mock token response - val tokenResponse = """ - { - "accessToken": "at_google_123", - "refreshToken": "rt_google_456", - "user": { - "id": "usr_g1", - "email": "google@test.com", - "displayName": "Google User", - "plan": "free", - "role": "user" - } - } - """.trimIndent() - server.enqueue(MockResponse().setBody(tokenResponse).setResponseCode(200)) - - // Act - val client = BLPlatformClient(config) { null } - // We can't easily use BLAuthClient directly (needs BLSecureStore with Context), - // so we test the HTTP layer directly via BLPlatformClient - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("idToken" to "mock_google_id_token"), - ) - val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) - - // Assert: correct endpoint called - val request = server.takeRequest() - assertEquals("POST", request.method) - assertTrue(request.path!!.contains("/api/auth/oauth/google")) - - // Assert: request body contains idToken - val requestBody = request.body.readUtf8() - assertTrue(requestBody.contains("mock_google_id_token")) - - // Assert: response parses correctly - val result = json.decodeFromString(response) - assertEquals("at_google_123", result.accessToken) - assertEquals("usr_g1", result.user.id) - assertEquals("google@test.com", result.user.email) - } - - // ── Test: MFA challenge detection ──────────────────────── - - @Test - fun `social login detects MFA challenge response`() = runTest { - // Arrange: mock MFA challenge response - val mfaResponse = """ - { - "mfaRequired": true, - "mfaChallenge": "ch_abc123", - "methods": ["totp", "recovery"] - } - """.trimIndent() - server.enqueue(MockResponse().setBody(mfaResponse).setResponseCode(200)) - - // Act: call the endpoint - val client = BLPlatformClient(config) { null } - val body = json.encodeToString( - MapSerializer(String.serializer(), String.serializer()), - mapOf("idToken" to "mock_token"), - ) - val response = client.request("POST", "/api/auth/oauth/google", body, skipAuth = true) - - // Assert: response is parseable as MfaChallenge - val challenge = json.decodeFromString(response) - assertTrue(challenge.mfaRequired) - assertEquals("ch_abc123", challenge.mfaChallenge) - assertEquals(listOf("totp", "recovery"), challenge.methods) - } - - // ── Test: SmartAuth types serialize/deserialize ────────── - - @Test - fun `SmartAuth types are correctly serializable`() { - // MfaStatus - val statusJson = """{"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6}""" - val status = json.decodeFromString(statusJson) - assertEquals(true, status.mfaEnabled) - assertEquals(6, status.recoveryCodesRemaining) - - // AuthProvider - val providerJson = """{"provider":"google","email":"test@test.com","linkedAt":"2026-01-01","lastUsedAt":null}""" - val provider = json.decodeFromString(providerJson) - assertEquals("google", provider.provider) - assertNull(provider.lastUsedAt) - - // Device - val deviceJson = """{"fingerprint":"fp_d1","trustLevel":"trusted","trustExpiresAt":"2026-06-01","createdAt":"2026-01-01","lastSeenAt":"2026-03-01","isTrusted":true}""" - val device = json.decodeFromString(deviceJson) - assertEquals("trusted", device.trustLevel) - - // LoginEvent - val eventJson = """{"id":"e1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01"}""" - val event = json.decodeFromString(eventJson) - assertEquals(15, event.riskScore) - assertEquals("SF", event.geo?.city) - - // TotpSetup - val totpJson = """{"otpauthUri":"otpauth://totp/test","qrCode":"data:image/png;base64,abc","recoveryCodes":["code1","code2"]}""" - val totp = json.decodeFromString(totpJson) - assertEquals(2, totp.recoveryCodes.size) - - // StepUpResponse - val stepUpJson = """{"stepUpToken":"su_abc123"}""" - val stepUp = json.decodeFromString(stepUpJson) - assertEquals("su_abc123", stepUp.stepUpToken) - } - - @Test - fun `MfaRequiredException contains challenge data`() { - val challenge = BLAuthClient.MfaChallenge( - mfaRequired = true, - mfaChallenge = "ch_test", - methods = listOf("totp"), - ) - val exception = BLAuthClient.MfaRequiredException(challenge) - assertEquals("MFA required", exception.message) - assertEquals("ch_test", exception.challenge.mfaChallenge) - } - - @Test - fun `AuthState MfaRequired holds challenge`() { - val challenge = BLAuthClient.MfaChallenge( - mfaRequired = true, - mfaChallenge = "ch_xyz", - methods = listOf("totp", "recovery"), - ) - val state = BLAuthClient.AuthState.MfaRequired(challenge) - assertTrue(state is BLAuthClient.AuthState.MfaRequired) - assertEquals("ch_xyz", (state as BLAuthClient.AuthState.MfaRequired).challenge.mfaChallenge) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt deleted file mode 100644 index 050a2ff..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFeatureFlagClientTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLFeatureFlagClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `isEnabled returns false before init`() { - val client = BLFeatureFlagClient(config) - assertFalse(client.isEnabled("any_flag")) - } - - @Test - fun `getAllFlags returns empty map before init`() { - val client = BLFeatureFlagClient(config) - assertTrue(client.getAllFlags().isEmpty()) - } - - @Test - fun `refresh fetches flags from server`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{"dark_mode":true,"beta_feature":false}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - assertTrue(client.isEnabled("dark_mode")) - assertFalse(client.isEnabled("beta_feature")) - assertFalse(client.isEnabled("unknown_flag")) - } - - @Test - fun `getAllFlags returns current flags after refresh`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{"a":true,"b":false}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val flags = client.getAllFlags() - assertEquals(2, flags.size) - assertTrue(flags["a"] == true) - assertTrue(flags["b"] == false) - } - - @Test - fun `keeps existing flags on server error`() = runTest { - // First: successful fetch - server.enqueue( - MockResponse() - .setBody("""{"flags":{"feature_x":true}}""") - .setResponseCode(200) - ) - // Second: server error - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLFeatureFlagClient(config) - client.refresh() - assertTrue(client.isEnabled("feature_x")) - - client.refresh() - // Should still have the old flags - assertTrue(client.isEnabled("feature_x")) - } - - @Test - fun `sends platform query parameter`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("platform=android")) - } - - @Test - fun `sends X-Product-Id header`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"flags":{}}""") - .setResponseCode(200) - ) - - val client = BLFeatureFlagClient(config) - client.refresh() - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } - - @Test - fun `stop cancels polling`() { - val client = BLFeatureFlagClient(config, pollIntervalMs = 100_000L) - client.init() - client.stop() - // No assertions needed — verifies stop() doesn't throw - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt deleted file mode 100644 index 969cc1a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLFieldEncryptTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.assertThrows -import javax.crypto.AEADBadTagException -import javax.crypto.SecretKey - -class BLFieldEncryptTest { - - private lateinit var key: SecretKey - private val dekId = "dek_user1_test" - - @BeforeEach - fun setUp() { - key = BLFieldEncrypt.generateKey() - } - - // ── Encrypt / Decrypt Roundtrip ───────────────────────── - - @Test - fun `encrypt decrypt roundtrip`() { - val plaintext = "Hello, World!" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt empty string`() { - val plaintext = "" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt unicode`() { - val plaintext = "こんにちは世界 \uD83C\uDF0D مرحبا Ñoño" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - @Test - fun `encrypt decrypt large payload`() { - val plaintext = "A".repeat(100_000) - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key) - assertEquals(plaintext, decrypted) - } - - // ── EncryptedField Structure ──────────────────────────── - - @Test - fun `encrypted field has correct sentinel`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - assertTrue(encrypted.__encrypted) - assertEquals(1, encrypted.v) - assertEquals("aes-256-gcm", encrypted.alg) - assertEquals(dekId, encrypted.dekId) - } - - @Test - fun `encrypted field has correct hex lengths`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - // IV: 12 bytes = 24 hex chars - assertEquals(24, encrypted.iv.length) - // Tag: 16 bytes = 32 hex chars - assertEquals(32, encrypted.tag.length) - // ct should be non-empty - assertTrue(encrypted.ct.isNotEmpty()) - } - - @Test - fun `each encryption produces unique IV`() { - val a = BLFieldEncrypt.encrypt("same", key, dekId) - val b = BLFieldEncrypt.encrypt("same", key, dekId) - assertNotEquals(a.iv, b.iv, "Each encryption should use a unique IV") - assertNotEquals(a.ct, b.ct, "Ciphertext should differ with different IVs") - } - - // ── AAD (Additional Authenticated Data) ───────────────── - - @Test - fun `encrypt decrypt with AAD`() { - val plaintext = "secret data" - val aad = "user123:notes" - val encrypted = BLFieldEncrypt.encrypt(plaintext, key, dekId, aad) - val decrypted = BLFieldEncrypt.decrypt(encrypted, key, aad) - assertEquals(plaintext, decrypted) - } - - @Test - fun `decrypt with wrong AAD fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "correct") - assertThrows { - BLFieldEncrypt.decrypt(encrypted, key, "wrong") - } - } - - @Test - fun `decrypt with missing AAD fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId, "some-aad") - assertThrows { - BLFieldEncrypt.decrypt(encrypted, key) - } - } - - // ── Wrong Key ─────────────────────────────────────────── - - @Test - fun `decrypt with wrong key fails`() { - val encrypted = BLFieldEncrypt.encrypt("secret", key, dekId) - val wrongKey = BLFieldEncrypt.generateKey() - assertThrows { - BLFieldEncrypt.decrypt(encrypted, wrongKey) - } - } - - // ── Key Size Validation ───────────────────────────────── - - @Test - fun `encrypt rejects short key`() { - val shortKeyBytes = ByteArray(16) // 128-bit instead of 256-bit - val shortKey = javax.crypto.spec.SecretKeySpec(shortKeyBytes, "AES") - assertThrows { - BLFieldEncrypt.encrypt("test", shortKey, dekId) - } - } - - // ── Key from Hex ──────────────────────────────────────── - - @Test - fun `key from hex`() { - val hex = "ab".repeat(32) // 64 hex chars = 32 bytes - val hexKey = BLFieldEncrypt.keyFromHex(hex) - val encrypted = BLFieldEncrypt.encrypt("test", hexKey, dekId) - val decrypted = BLFieldEncrypt.decrypt(encrypted, hexKey) - assertEquals("test", decrypted) - } - - @Test - fun `key from hex rejects invalid length`() { - assertThrows { - BLFieldEncrypt.keyFromHex("aabb") - } - } - - // ── isEncrypted Type Guard ────────────────────────────── - - @Test - fun `isEncrypted with valid field`() { - val encrypted = BLFieldEncrypt.encrypt("test", key, dekId) - assertTrue(BLFieldEncrypt.isEncrypted(encrypted)) - } - - @Test - fun `isEncrypted with null field`() { - assertFalse(BLFieldEncrypt.isEncrypted(null as BLEncryptedField?)) - } - - @Test - fun `isEncrypted with map`() { - val map = mapOf( - "__encrypted" to true, - "v" to 1, - "alg" to "aes-256-gcm", - "ct" to "abcd", - "iv" to "1234", - "tag" to "5678", - "dekId" to "dek_test", - ) - assertTrue(BLFieldEncrypt.isEncrypted(map)) - } - - @Test - fun `isEncrypted with incomplete map`() { - val map = mapOf("__encrypted" to true, "v" to 1) - assertFalse(BLFieldEncrypt.isEncrypted(map)) - } - - @Test - fun `isEncrypted with null map`() { - assertFalse(BLFieldEncrypt.isEncrypted(null as Map?)) - } - - // ── Hex Helpers ───────────────────────────────────────── - - @Test - fun `byte array hex roundtrip`() { - val original = byteArrayOf(0x00, 0x0f, 0xff.toByte(), 0xab.toByte(), 0xcd.toByte()) - val hex = original.toHexString() - assertEquals("000fffabcd", hex) - val restored = hex.hexToByteArray() - assertArrayEquals(original, restored) - } - - @Test - fun `hex to byte array rejects odd length`() { - assertThrows { - "a".hexToByteArray() - } - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt deleted file mode 100644 index 0f2438d..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchClientTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLKillSwitchClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `returns ok when not disabled`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `returns disabled with message`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":true,"message":"Maintenance in progress"}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertTrue(result.disabled) - assertEquals("Maintenance in progress", result.message) - } - - @Test - fun `fail-open on server error`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `fail-open on network error`() = runTest { - server.shutdown() // Force connection failure - - val client = BLKillSwitchClient(config) - val result = client.check() - - assertFalse(result.disabled) - } - - @Test - fun `sends correct query parameters`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - client.check() - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("platform=android")) - assertEquals("GET", recorded.method) - } - - @Test - fun `sends X-Product-Id header`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"disabled":false}""") - .setResponseCode(200) - ) - - val client = BLKillSwitchClient(config) - client.check() - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt deleted file mode 100644 index bf1825c..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLKillSwitchResultTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLKillSwitchResultTest { - - @Test - fun `ok() should return not-disabled result`() { - val result = BLKillSwitchClient.KillSwitchResult.ok() - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `disabled result should have message`() { - val result = BLKillSwitchClient.KillSwitchResult( - disabled = true, - message = "Under maintenance" - ) - assertTrue(result.disabled) - assertEquals("Under maintenance", result.message) - } - - @Test - fun `default result should not be disabled`() { - val result = BLKillSwitchClient.KillSwitchResult() - assertFalse(result.disabled) - assertNull(result.message) - } - - @Test - fun `data class copy should work`() { - val original = BLKillSwitchClient.KillSwitchResult.ok() - val modified = original.copy(disabled = true, message = "Updating") - assertTrue(modified.disabled) - assertEquals("Updating", modified.message) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt deleted file mode 100644 index 8108801..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLLicenseClientTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLLicenseClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `activate returns success result`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"pro","message":"Activated"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - val result = client.activate("LYSNR-ABCD-1234") - - assertTrue(result.success) - assertEquals("pro", result.plan) - assertEquals("Activated", result.message) - } - - @Test - fun `activate URL-encodes the license key`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"pro"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - client.activate("KEY+WITH SPACE") - - val recorded = server.takeRequest() - assertTrue(recorded.path!!.contains("KEY%2BWITH+SPACE") || recorded.path!!.contains("KEY%2BWITH%20SPACE")) - } - - @Test - fun `activate sends productId in body`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"success":true,"plan":"free"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - client.activate("TEST-KEY") - - val recorded = server.takeRequest() - assertTrue(recorded.body.readUtf8().contains("testapp")) - } - - @Test - fun `activate returns failure on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid"}""")) - - val client = BLLicenseClient(config) - val result = client.activate("BAD-KEY") - - assertFalse(result.success) - assertNotNull(result.message) - } - - @Test - fun `checkStatus returns valid license`() = runTest { - server.enqueue( - MockResponse() - .setBody("""{"valid":true,"plan":"pro","expiresAt":"2027-01-01"}""") - .setResponseCode(200) - ) - - val client = BLLicenseClient(config) - val result = client.checkStatus("LYSNR-ABCD-1234") - - assertTrue(result.valid) - assertEquals("pro", result.plan) - assertEquals("2027-01-01", result.expiresAt) - } - - @Test - fun `checkStatus returns invalid on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not found"}""")) - - val client = BLLicenseClient(config) - val result = client.checkStatus("UNKNOWN") - - assertFalse(result.valid) - } - - @Test - fun `deactivate returns true on success`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLLicenseClient(config) - val result = client.deactivate("LYSNR-ABCD-1234") - - assertTrue(result) - val recorded = server.takeRequest() - assertEquals("POST", recorded.method) - assertTrue(recorded.path!!.contains("deactivate")) - } - - @Test - fun `deactivate returns false on error`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLLicenseClient(config) - val result = client.deactivate("LYSNR-ABCD-1234") - - assertFalse(result) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt deleted file mode 100644 index c7e801f..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformClientTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.coroutines.test.runTest -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -class BLPlatformClientTest { - - private lateinit var server: MockWebServer - private lateinit var config: BLPlatformConfig - - @BeforeEach - fun setup() { - server = MockWebServer() - server.start() - config = BLPlatformConfig( - productId = "testapp", - baseUrl = server.url("/api").toString().trimEnd('/'), - applicationId = "com.test.app", - timeoutMs = 5_000L, - ) - } - - @AfterEach - fun teardown() { - server.shutdown() - } - - @Test - fun `GET request returns response body`() = runTest { - server.enqueue(MockResponse().setBody("""{"ok":true}""").setResponseCode(200)) - - val client = BLPlatformClient(config) - val result = client.request("GET", "/health") - - assertEquals("""{"ok":true}""", result) - val recorded = server.takeRequest() - assertEquals("GET", recorded.method) - assertTrue(recorded.path!!.endsWith("/api/health")) - } - - @Test - fun `POST request sends body`() = runTest { - server.enqueue(MockResponse().setBody("""{"id":"1"}""").setResponseCode(201)) - - val client = BLPlatformClient(config) - val result = client.request("POST", "/items", body = """{"name":"test"}""") - - assertEquals("""{"id":"1"}""", result) - val recorded = server.takeRequest() - assertEquals("POST", recorded.method) - assertEquals("""{"name":"test"}""", recorded.body.readUtf8()) - } - - @Test - fun `injects X-Product-Id header`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertEquals("testapp", recorded.getHeader("X-Product-Id")) - } - - @Test - fun `injects X-Request-Id header`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test") - - val recorded = server.takeRequest() - val requestId = recorded.getHeader("X-Request-Id") - assertNotNull(requestId) - assertTrue(requestId!!.isNotBlank()) - } - - @Test - fun `injects Authorization header when token provided`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { "my-token-123" } - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertEquals("Bearer my-token-123", recorded.getHeader("Authorization")) - } - - @Test - fun `omits Authorization header when no token`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { null } - client.request("GET", "/test") - - val recorded = server.takeRequest() - assertNull(recorded.getHeader("Authorization")) - } - - @Test - fun `skips auth when skipAuth is true`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) { "should-not-appear" } - client.request("GET", "/test", skipAuth = true) - - val recorded = server.takeRequest() - assertNull(recorded.getHeader("Authorization")) - } - - @Test - fun `throws BLApiException on non-2xx`() = runTest { - server.enqueue(MockResponse().setBody("""{"error":"not found"}""").setResponseCode(404)) - - val client = BLPlatformClient(config) - val ex = assertThrows(BLApiException::class.java) { - kotlinx.coroutines.runBlocking { - client.request("GET", "/missing") - } - } - assertEquals(404, ex.statusCode) - assertTrue(ex.responseBody.contains("not found")) - } - - @Test - fun `fireAndForget swallows errors`() = runTest { - server.enqueue(MockResponse().setResponseCode(500)) - - val client = BLPlatformClient(config) - // Should not throw - client.fireAndForget("POST", "/telemetry", body = """{"event":"test"}""") - - assertEquals(1, server.requestCount) - } - - @Test - fun `PUT request works`() = runTest { - server.enqueue(MockResponse().setBody("""{"updated":true}""").setResponseCode(200)) - - val client = BLPlatformClient(config) - val result = client.request("PUT", "/items/1", body = """{"name":"updated"}""") - - assertEquals("""{"updated":true}""", result) - val recorded = server.takeRequest() - assertEquals("PUT", recorded.method) - } - - @Test - fun `DELETE request works`() = runTest { - server.enqueue(MockResponse().setBody("").setResponseCode(204)) - - val client = BLPlatformClient(config) - val result = client.request("DELETE", "/items/1") - - assertEquals("", result) - val recorded = server.takeRequest() - assertEquals("DELETE", recorded.method) - } - - @Test - fun `extra headers are sent`() = runTest { - server.enqueue(MockResponse().setBody("{}").setResponseCode(200)) - - val client = BLPlatformClient(config) - client.request("GET", "/test", extraHeaders = mapOf("X-Custom" to "value")) - - val recorded = server.takeRequest() - assertEquals("value", recorded.getHeader("X-Custom")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt deleted file mode 100644 index b40ba6e..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLPlatformConfigTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.bytelyst.platform - -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLPlatformConfigTest { - - @Test - fun `should create config with required fields`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com/api", - applicationId = "com.test.app", - ) - assertEquals("testapp", config.productId) - assertEquals("https://api.test.com/api", config.baseUrl) - assertEquals("com.test.app", config.applicationId) - } - - @Test - fun `should have sensible defaults`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com", - applicationId = "com.test.app", - ) - assertEquals("android", config.platform) - assertEquals("native", config.channel) - assertEquals(15_000L, config.timeoutMs) - } - - @Test - fun `should allow overriding defaults`() { - val config = BLPlatformConfig( - productId = "testapp", - baseUrl = "https://api.test.com", - platform = "wear_os", - channel = "keyboard", - applicationId = "com.test.app", - timeoutMs = 5_000L, - ) - assertEquals("wear_os", config.platform) - assertEquals("keyboard", config.channel) - assertEquals(5_000L, config.timeoutMs) - } - - @Test - fun `data class equality should work`() { - val a = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - val b = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - assertEquals(a, b) - assertEquals(a.hashCode(), b.hashCode()) - } - - @Test - fun `copy should create modified config`() { - val original = BLPlatformConfig(productId = "x", baseUrl = "http://a", applicationId = "com.x") - val modified = original.copy(productId = "y") - assertEquals("y", modified.productId) - assertEquals(original.baseUrl, modified.baseUrl) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt deleted file mode 100644 index 6d4de1a..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/BLTelemetryEventTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.bytelyst.platform - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class BLTelemetryEventTest { - - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - - @Test - fun `event should serialize to JSON`() { - val event = BLTelemetryClient.TelemetryEvent( - id = "evt-1", - productId = "testapp", - anonymousInstallId = "inst-1", - sessionId = "sess-1", - platform = "android", - channel = "native", - osFamily = "android", - osVersion = "14", - appVersion = "1.0.0", - buildNumber = "42", - releaseChannel = "beta", - eventType = "info", - module = "test", - eventName = "unit_test", - occurredAt = "2026-01-01T00:00:00Z", - ) - val jsonStr = json.encodeToString(event) - assertTrue(jsonStr.contains("\"productId\":\"testapp\"")) - assertTrue(jsonStr.contains("\"eventName\":\"unit_test\"")) - } - - @Test - fun `event should deserialize from JSON`() { - val jsonStr = """ - { - "id": "evt-1", - "productId": "testapp", - "anonymousInstallId": "inst-1", - "sessionId": "sess-1", - "platform": "android", - "channel": "native", - "osFamily": "android", - "osVersion": "14", - "appVersion": "1.0.0", - "buildNumber": "42", - "releaseChannel": "beta", - "eventType": "info", - "module": "test", - "eventName": "unit_test", - "occurredAt": "2026-01-01T00:00:00Z" - } - """.trimIndent() - val event = json.decodeFromString(jsonStr) - assertEquals("testapp", event.productId) - assertEquals("unit_test", event.eventName) - assertNull(event.feature) - assertNull(event.tags) - } - - @Test - fun `event with optional fields should serialize correctly`() { - val event = BLTelemetryClient.TelemetryEvent( - id = "evt-2", - productId = "testapp", - anonymousInstallId = "inst-1", - sessionId = "sess-1", - platform = "android", - channel = "native", - osFamily = "android", - osVersion = "14", - appVersion = "1.0.0", - buildNumber = "42", - releaseChannel = "beta", - eventType = "error", - module = "auth", - eventName = "login_failed", - feature = "social_login", - message = "Token expired", - tags = mapOf("provider" to "google"), - metrics = mapOf("retryCount" to 3.0), - occurredAt = "2026-01-01T00:00:00Z", - ) - val jsonStr = json.encodeToString(event) - assertTrue(jsonStr.contains("\"feature\":\"social_login\"")) - assertTrue(jsonStr.contains("\"provider\":\"google\"")) - assertTrue(jsonStr.contains("\"retryCount\":3.0")) - } -} diff --git a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt b/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt deleted file mode 100644 index 5b1309b..0000000 --- a/vendor/bytelyst/kotlin-platform-sdk/src/test/kotlin/com/bytelyst/platform/diagnostics/DiagnosticsTypesTest.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.bytelyst.platform.diagnostics - -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.BeforeEach - -class DiagnosticsTypesTest { - - @Test - fun `test DiagnosticsSession creation`() { - val session = DiagnosticsSession( - id = "ds_test123", - productId = "test-app", - status = DiagnosticsSessionStatus.ACTIVE, - collectionLevel = DiagnosticsCollectionLevel.DEBUG, - captureLogs = true, - captureNetwork = true, - captureScreenshots = false, - screenshotOnError = true, - maxDurationMinutes = 60, - createdAt = "2026-03-03T12:00:00Z", - expiresAt = "2026-03-03T13:00:00Z" - ) - - assertEquals("ds_test123", session.id) - assertEquals(DiagnosticsSessionStatus.ACTIVE, session.status) - assertEquals(DiagnosticsCollectionLevel.DEBUG, session.collectionLevel) - assertTrue(session.captureLogs) - } - - @Test - fun `test DiagnosticsLogEntry creation`() { - val entry = DiagnosticsLogEntry( - level = DiagnosticsLogLevel.ERROR, - message = "Something went wrong", - timestamp = "2026-03-03T12:00:00Z", - module = "TestModule", - file = "Test.kt", - line = 42, - function = "testFunction", - context = mapOf("key" to "value"), - correlationId = "corr-123" - ) - - assertEquals(DiagnosticsLogLevel.ERROR, entry.level) - assertEquals("Something went wrong", entry.message) - assertEquals(42, entry.line) - } - - @Test - fun `test DiagnosticsTraceSpan creation`() { - val span = DiagnosticsTraceSpan( - spanId = "span-123", - parentId = "parent-456", - name = "test-span", - kind = DiagnosticsSpanKind.INTERNAL, - startTime = "2026-03-03T12:00:00Z", - endTime = "2026-03-03T12:00:01Z", - durationMs = 1000.0, - attributes = mapOf("key" to "value"), - status = DiagnosticsSpanStatus.OK - ) - - assertEquals("span-123", span.spanId) - assertEquals("parent-456", span.parentId) - assertEquals(DiagnosticsSpanStatus.OK, span.status) - } - - @Test - fun `test DiagnosticsBreadcrumb creation`() { - val breadcrumb = DiagnosticsBreadcrumb( - timestamp = "2026-03-03T12:00:00Z", - category = "navigation", - message = "User tapped button", - data = mapOf("buttonId" to "submit") - ) - - assertEquals("navigation", breadcrumb.category) - assertEquals("User tapped button", breadcrumb.message) - } - - @Test - fun `test DiagnosticsNetworkRequest creation`() { - val request = DiagnosticsNetworkRequest( - id = "req-123", - url = "https://api.example.com/test", - method = "POST", - requestHeaders = mapOf("Content-Type" to "application/json"), - requestBody = "{}", - status = 200, - startTime = "2026-03-03T12:00:00Z", - endTime = "2026-03-03T12:00:01Z", - durationMs = 100.0 - ) - - assertEquals("req-123", request.id) - assertEquals(200, request.status) - assertEquals(100.0, request.durationMs!!, 0.0) - } - - @Test - fun `test DiagnosticsDeviceState creation`() { - val state = DiagnosticsDeviceState( - memoryMB = 1024, - batteryLevel = 0.75f, - isCharging = true, - storageMB = 512, - networkType = "wifi", - isOnline = true, - thermalState = DiagnosticsThermalState.NOMINAL - ) - - assertEquals(1024, state.memoryMB) - assertEquals(0.75f, state.batteryLevel) - assertTrue(state.isCharging == true) - assertEquals(DiagnosticsThermalState.NOMINAL, state.thermalState) - } -} - -class BreadcrumbTrailTest { - - private lateinit var trail: BreadcrumbTrail - - @BeforeEach - fun setup() { - trail = BreadcrumbTrail(maxSize = 3) - } - - @Test - fun `test add breadcrumb`() { - trail.add(category = "test", message = "test message") - assertEquals(1, trail.size()) - } - - @Test - fun `test evict oldest when over limit`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - trail.add(category = "c", message = "3") - trail.add(category = "d", message = "4") - - assertEquals(3, trail.size()) - val all = trail.getAll() - assertEquals("b", all[0].category) // First one evicted - } - - @Test - fun `test get last n breadcrumbs`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - trail.add(category = "c", message = "3") - - val last2 = trail.getLast(2) - assertEquals(2, last2.size) - assertEquals("b", last2[0].category) - } - - @Test - fun `test get most recent`() { - trail.add(category = "a", message = "1") - trail.add(category = "b", message = "2") - - val recent = trail.getMostRecent() - assertEquals("b", recent?.category) - } - - @Test - fun `test clear`() { - trail.add(category = "a", message = "1") - trail.clear() - assertEquals(0, trail.size()) - } -} - -class DiagnosticsConfigurationTest { - - @Test - fun `test configuration creation`() { - val config = DiagnosticsConfiguration( - productId = "test-app", - anonymousInstallId = "install-123", - platform = "android", - channel = "android_app", - osFamily = "android", - appVersion = "1.0.0", - buildNumber = "100", - releaseChannel = "beta", - serverUrl = "https://api.test.com" - ) - - assertEquals("test-app", config.productId) - assertEquals("install-123", config.anonymousInstallId) - assertEquals(5000, config.pollIntervalMs) // Default - assertEquals(100, config.maxBreadcrumbs) // Default - } - - @Test - fun `test configuration with custom values`() { - val config = DiagnosticsConfiguration( - productId = "test-app", - anonymousInstallId = "install-123", - platform = "android", - channel = "android_app", - osFamily = "android", - appVersion = "1.0.0", - buildNumber = "100", - releaseChannel = "beta", - serverUrl = "https://api.test.com", - pollIntervalMs = 10000, - maxBreadcrumbs = 50 - ) - - assertEquals(10000, config.pollIntervalMs) - assertEquals(50, config.maxBreadcrumbs) - } -} diff --git a/vendor/bytelyst/llm-router/README.md b/vendor/bytelyst/llm-router/README.md deleted file mode 100644 index b15c376..0000000 --- a/vendor/bytelyst/llm-router/README.md +++ /dev/null @@ -1,134 +0,0 @@ -# @bytelyst/llm-router - -Pure-code LLM router for free-tier API providers. No LLM-in-the-loop — deterministic routing with automatic fallback, health tracking, and round-robin load distribution. - -## Features - -- **4 free providers** out of the box: Groq, OpenRouter, Together AI, Cerebras -- **Prompt classification** — regex-based detection of code/math/reasoning/creative prompts -- **Smart selection** — routes to the best model for each prompt category -- **Round-robin** — distributes load across providers to maximize free-tier usage -- **Auto-fallback** — retries on 429/5xx with next-best provider -- **Health tracking** — sliding-window stats (latency, error rate, rate-limit rate) -- **Telemetry hook** — log every routing decision for analysis -- **OpenAI-compatible** — same request/response format as OpenAI chat completions -- **Zero dependencies** — pure TypeScript, uses native `fetch` - -## Quick Start - -```bash -# Set at least one API key -export GROQ_API_KEY=gsk_... -export OPENROUTER_API_KEY=sk-or-... -export TOGETHER_API_KEY=... -export CEREBRAS_API_KEY=... -``` - -```typescript -import { LlmRouter } from '@bytelyst/llm-router'; - -const router = new LlmRouter(); - -// Automatic routing — classifier picks best provider+model -const result = await router.chat({ - messages: [{ role: 'user', content: 'Write a quicksort in TypeScript' }], -}); - -console.log(result.response.choices[0].message.content); -console.log(`Served by: ${result.provider}/${result.model} in ${result.totalLatencyMs}ms`); -``` - -## Explicit Provider Routing - -```typescript -// Force a specific provider:model -const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'groq:llama-3.3-70b-versatile', -}); -``` - -## Telemetry - -```typescript -const router = new LlmRouter({ - onTelemetry: entry => { - // entry: { event, provider, model, attempt, latencyMs, category, tokens?, error? } - console.log(`[${entry.event}] ${entry.provider}/${entry.model} — ${entry.latencyMs}ms`); - }, -}); -``` - -## Health Monitoring - -```typescript -const snapshots = router.getHealth(); -// Returns: HealthSnapshot[] with per-provider stats -// { provider, model, totalRequests, successes, rateLimits, errors, avgLatencyMs, p95LatencyMs, healthy } -``` - -## Configuration - -```typescript -const router = new LlmRouter({ - // Override default providers - providers: [...], - // Health window (default: 60s) - healthWindowMs: 120_000, - // Error rate to mark unhealthy (default: 50%) - errorThreshold: 0.4, - // Rate-limit rate to mark unhealthy (default: 30%) - rateLimitThreshold: 0.2, - // Request timeout (default: 30s) - timeoutMs: 15_000, - // Max retry attempts (default: 3) - maxRetries: 4, -}); -``` - -## Provider Selection Logic - -1. **Classify** prompt → code, math, reasoning, creative, or general -2. **Score** each available model based on category match, speed tier, context window, and model size -3. **Filter** unhealthy models (based on sliding-window error/rate-limit rates) -4. **Round-robin** across top-scoring providers to spread rate-limit load -5. **Fallback** on 429/5xx → exclude failed model, pick next best - -## Default Provider Registry - -| Provider | Models | Speed | Strengths | -| -------------- | ---------------------------------------- | ---------- | ------------------------ | -| **Groq** | Llama 3.3 70B, Llama 3.1 8B, Gemma 2 9B | ⚡ Fastest | General, reasoning, code | -| **OpenRouter** | DeepSeek R1, Llama 3.3 70B, Gemma 2 9B | Medium | Reasoning, code, math | -| **Together** | Llama 3.3 70B Turbo, DeepSeek R1 Distill | Medium | General, reasoning, code | -| **Cerebras** | Llama 3.3 70B | ⚡ Fastest | General, reasoning, code | - -## Adding Custom Providers - -Any OpenAI-compatible endpoint works: - -```typescript -import { LlmRouter, DEFAULT_PROVIDERS } from '@bytelyst/llm-router'; - -const router = new LlmRouter({ - providers: [ - ...DEFAULT_PROVIDERS, - { - name: 'my-provider', - baseUrl: 'https://my-api.example.com/v1', - apiKeyEnv: 'MY_PROVIDER_KEY', - rpmLimit: 60, - tpmLimit: 100_000, - models: [ - { - id: 'my-model', - label: 'My Model', - contextWindow: 32_000, - strengths: ['general', 'code'], - speedTier: 2, - }, - ], - }, - ], -}); -``` diff --git a/vendor/bytelyst/llm-router/package.json b/vendor/bytelyst/llm-router/package.json deleted file mode 100644 index 873e1f9..0000000 --- a/vendor/bytelyst/llm-router/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@bytelyst/llm-router", - "version": "0.1.5", - "description": "Pure-code LLM router for free-tier API providers with round-robin, fallback, and health tracking", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "vitest": "^3.0.0", - "typescript": "^5.7.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts b/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts deleted file mode 100644 index 440bfbe..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/classifier.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { classifyPrompt } from '../classifier.js'; - -describe('classifyPrompt', () => { - it('classifies code prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Write a typescript function to sort an array' }, - ]); - expect(result.category).toBe('code'); - expect(result.estimatedTokens).toBeGreaterThan(0); - }); - - it('classifies code with keywords like refactor and debug', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Debug this error in my React component and refactor the handler' }, - ]); - expect(result.category).toBe('code'); - }); - - it('classifies math prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Calculate the integral of x^2 from 0 to 5' }, - ]); - expect(result.category).toBe('math'); - }); - - it('classifies reasoning prompts', () => { - const result = classifyPrompt([ - { - role: 'user', - content: - 'Explain step by step why this approach has trade-offs and analyze the implications', - }, - ]); - expect(result.category).toBe('reasoning'); - }); - - it('classifies creative prompts', () => { - const result = classifyPrompt([ - { role: 'user', content: 'Write a short story about a robot who learns to paint' }, - ]); - expect(result.category).toBe('creative'); - }); - - it('defaults to general for ambiguous prompts', () => { - const result = classifyPrompt([{ role: 'user', content: 'Hello, how are you?' }]); - expect(result.category).toBe('general'); - }); - - it('estimates tokens roughly correctly', () => { - const text = 'a'.repeat(400); // ~100 tokens - const result = classifyPrompt([{ role: 'user', content: text }]); - expect(result.estimatedTokens).toBe(100); - }); - - it('handles multi-message conversations', () => { - const result = classifyPrompt([ - { role: 'system', content: 'You are a coding assistant' }, - { role: 'user', content: 'Fix the bug in my python function' }, - ]); - expect(result.category).toBe('code'); - }); - - it('detects code blocks in backticks', () => { - const result = classifyPrompt([ - { - role: 'user', - content: 'What is wrong with this?\n```\nconst x = 1;\nconsole.log(x);\n```', - }, - ]); - expect(result.category).toBe('code'); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/health.test.ts b/vendor/bytelyst/llm-router/src/__tests__/health.test.ts deleted file mode 100644 index 31f864d..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/health.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { HealthTracker } from '../health.js'; - -describe('HealthTracker', () => { - let tracker: HealthTracker; - - beforeEach(() => { - tracker = new HealthTracker({ windowMs: 10_000, errorThreshold: 0.5, rateLimitThreshold: 0.3 }); - }); - - it('reports healthy with no data', () => { - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('reports healthy with all successes', () => { - for (let i = 0; i < 5; i++) { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'success', - }); - } - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('marks unhealthy when error rate exceeds threshold', () => { - for (let i = 0; i < 5; i++) { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - } - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(false); - }); - - it('marks unhealthy when rate-limit rate exceeds threshold', () => { - // 2 successes + 3 rate limits = 60% rate limit rate > 30% threshold - for (let i = 0; i < 2; i++) { - tracker.record('openrouter', 'model-a', { - timestamp: Date.now(), - latencyMs: 100, - status: 'success', - }); - } - for (let i = 0; i < 3; i++) { - tracker.record('openrouter', 'model-a', { - timestamp: Date.now(), - latencyMs: 50, - status: 'rate_limit', - }); - } - expect(tracker.isHealthy('openrouter', 'model-a')).toBe(false); - }); - - it('assumes healthy with fewer than 3 records', () => { - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - tracker.record('groq', 'llama-3.3-70b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'error', - }); - // Only 2 records — not enough data, should still be healthy - expect(tracker.isHealthy('groq', 'llama-3.3-70b')).toBe(true); - }); - - it('computes avg and p95 latency', () => { - const latencies = [100, 200, 300, 400, 500]; - for (const latencyMs of latencies) { - tracker.record('groq', 'model-a', { - timestamp: Date.now(), - latencyMs, - status: 'success', - }); - } - const snap = tracker.snapshot('groq', 'model-a'); - expect(snap.avgLatencyMs).toBe(300); - expect(snap.p95LatencyMs).toBe(500); - expect(snap.successes).toBe(5); - }); - - it('tracks different providers independently', () => { - tracker.record('groq', 'model-a', { - timestamp: Date.now(), - latencyMs: 100, - status: 'success', - }); - tracker.record('openrouter', 'model-b', { - timestamp: Date.now(), - latencyMs: 500, - status: 'error', - }); - - const snapA = tracker.snapshot('groq', 'model-a'); - const snapB = tracker.snapshot('openrouter', 'model-b'); - expect(snapA.successes).toBe(1); - expect(snapB.errors).toBe(1); - }); - - it('returns all snapshots', () => { - tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); - tracker.record('together', 'model-b', { - timestamp: Date.now(), - latencyMs: 200, - status: 'success', - }); - - const all = tracker.allSnapshots(); - expect(all).toHaveLength(2); - }); - - it('resets all data', () => { - tracker.record('groq', 'model-a', { timestamp: Date.now(), latencyMs: 100, status: 'success' }); - tracker.reset(); - expect(tracker.allSnapshots()).toHaveLength(0); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts b/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts deleted file mode 100644 index 793ab86..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/registry.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - createLocalOllamaProvider, - getAvailableProviders, - DEFAULT_PROVIDERS, -} from '../registry.js'; -import type { ProviderConfig } from '../types.js'; - -describe('getAvailableProviders', () => { - const saved: Record = {}; - - beforeEach(() => { - // Save and clear all default provider env vars - for (const p of DEFAULT_PROVIDERS) { - const key = p.apiKeyEnv!; - saved[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - // Restore original env - for (const [key, val] of Object.entries(saved)) { - if (val === undefined) { - delete process.env[key]; - } else { - process.env[key] = val; - } - } - }); - - it('returns empty array when no API keys are set', () => { - expect(getAvailableProviders()).toEqual([]); - }); - - it('returns only providers with API keys set', () => { - process.env.GROQ_API_KEY = 'gsk_test'; - const result = getAvailableProviders(); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('groq'); - }); - - it('returns multiple providers when multiple keys are set', () => { - process.env.GROQ_API_KEY = 'gsk_test'; - process.env.CEREBRAS_API_KEY = 'csk_test'; - const result = getAvailableProviders(); - expect(result).toHaveLength(2); - const names = result.map(p => p.name); - expect(names).toContain('groq'); - expect(names).toContain('cerebras'); - }); - - it('excludes providers with empty string API key', () => { - process.env.GROQ_API_KEY = ''; - expect(getAvailableProviders()).toEqual([]); - }); - - it('works with custom provider list', () => { - const custom: ProviderConfig[] = [ - { - name: 'custom', - baseUrl: 'https://example.com/v1', - apiKeyEnv: 'CUSTOM_TEST_KEY', - rpmLimit: 10, - tpmLimit: 0, - models: [], - }, - ]; - expect(getAvailableProviders(custom)).toEqual([]); - - process.env.CUSTOM_TEST_KEY = 'test'; - expect(getAvailableProviders(custom)).toHaveLength(1); - delete process.env.CUSTOM_TEST_KEY; - }); - - it('includes local providers that do not require API keys', () => { - const local = createLocalOllamaProvider(['qwen2.5-coder:7b']); - const result = getAvailableProviders([local]); - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('local-ollama'); - }); - - it('infers local ollama model metadata for routing', () => { - const local = createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:70b']); - - expect(local.baseUrl).toBe('http://localhost:11434/v1'); - expect(local.models).toEqual([ - expect.objectContaining({ - id: 'qwen2.5-coder:7b', - contextWindow: 32_768, - speedTier: 1, - strengths: expect.arrayContaining(['code']), - }), - expect.objectContaining({ - id: 'llama3.1:70b', - contextWindow: 8_192, - speedTier: 3, - strengths: expect.arrayContaining(['general']), - }), - ]); - }); - - it('DEFAULT_PROVIDERS includes all 4 providers', () => { - expect(DEFAULT_PROVIDERS).toHaveLength(4); - const names = DEFAULT_PROVIDERS.map(p => p.name); - expect(names).toEqual(['groq', 'openrouter', 'together', 'cerebras']); - }); - - it('OpenRouter provider has recommended extra headers', () => { - const openrouter = DEFAULT_PROVIDERS.find(p => p.name === 'openrouter'); - expect(openrouter?.extraHeaders).toBeDefined(); - expect(openrouter?.extraHeaders?.['HTTP-Referer']).toBeDefined(); - expect(openrouter?.extraHeaders?.['X-Title']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/router.test.ts b/vendor/bytelyst/llm-router/src/__tests__/router.test.ts deleted file mode 100644 index cf90082..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/router.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { LlmRouter } from '../router.js'; -import { createLocalOllamaProvider } from '../registry.js'; -import type { ProviderConfig, ChatCompletionResponse } from '../types.js'; -import * as client from '../client.js'; - -// Mock the HTTP client -vi.mock('../client.js', () => ({ - sendChatCompletion: vi.fn(), -})); - -const MOCK_RESPONSE: ChatCompletionResponse = { - id: 'chatcmpl-test', - object: 'chat.completion', - created: Date.now(), - model: 'test-model', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Hello!' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, -}; - -const TEST_PROVIDERS: ProviderConfig[] = [ - { - name: 'test-fast', - baseUrl: 'https://fast.test/v1', - apiKeyEnv: 'TEST_FAST_KEY', - rpmLimit: 30, - tpmLimit: 10_000, - models: [ - { - id: 'fast-model', - label: 'Fast', - contextWindow: 8_192, - strengths: ['general'], - speedTier: 1, - }, - ], - }, - { - name: 'test-quality', - baseUrl: 'https://quality.test/v1', - apiKeyEnv: 'TEST_QUALITY_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'quality-model', - label: 'Quality', - contextWindow: 128_000, - strengths: ['code', 'reasoning'], - speedTier: 2, - }, - ], - }, -]; - -describe('LlmRouter', () => { - beforeEach(() => { - vi.resetAllMocks(); - // Set fake API keys - process.env.TEST_FAST_KEY = 'test-key-fast'; - process.env.TEST_QUALITY_KEY = 'test-key-quality'; - }); - - afterEach(() => { - delete process.env.TEST_FAST_KEY; - delete process.env.TEST_QUALITY_KEY; - }); - - it('throws if no providers have API keys', () => { - delete process.env.TEST_FAST_KEY; - delete process.env.TEST_QUALITY_KEY; - expect(() => new LlmRouter({ providers: TEST_PROVIDERS })).toThrow('No providers available'); - }); - - it('routes a simple prompt to a provider', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.response.choices[0]!.message.content).toBe('Hello!'); - expect(result.attempts).toBe(1); - expect(result.provider).toBe('test-fast'); - expect(result.model).toBe('fast-model'); - }); - - it('retries on 429 with fallback provider', async () => { - // First call: rate limited - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - // Second call: success - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.attempts).toBe(2); - expect(result.response.choices[0]!.message.content).toBe('Hello!'); - }); - - it('retries on error with fallback provider', async () => { - // First call: error - vi.mocked(client.sendChatCompletion).mockRejectedValueOnce(new Error('Network error')); - // Second call: success - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - }); - - expect(result.attempts).toBe(2); - }); - - it('throws after exhausting all retries', async () => { - vi.mocked(client.sendChatCompletion).mockRejectedValue(new Error('All down')); - - const router = new LlmRouter({ providers: TEST_PROVIDERS, maxRetries: 2 }); - await expect(router.chat({ messages: [{ role: 'user', content: 'Hello' }] })).rejects.toThrow( - 'All providers exhausted' - ); - }); - - it('routes code prompts to code-capable models', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await router.chat({ - messages: [{ role: 'user', content: 'Write a typescript function to sort an array' }], - }); - - // Should have been called with quality-model (has 'code' strength) - const callArgs = vi.mocked(client.sendChatCompletion).mock.calls[0]!; - expect(callArgs[1]).toBe('quality-model'); - }); - - it('fires telemetry callback on success', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ event: 'success', attempt: 1 }) - ); - }); - - it('fires telemetry callback on rate limit', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 200, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - expect(telemetry).toHaveBeenCalledWith(expect.objectContaining({ event: 'rate_limit' })); - }); - - it('handles explicit provider:model routing', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 100, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const result = await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'test-fast:fast-model', - }); - - expect(result.provider).toBe('test-fast'); - expect(result.model).toBe('fast-model'); - }); - - it('throws for unknown explicit provider', async () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await expect( - router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'unknown:model' }) - ).rejects.toThrow('Provider "unknown" not found'); - }); - - it('returns health snapshots', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 150, - status: 200, - }); - - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - await router.chat({ messages: [{ role: 'user', content: 'Hello' }] }); - - const health = router.getHealth(); - expect(health).toContainEqual( - expect.objectContaining({ - provider: 'test-fast', - model: 'fast-model', - successes: 1, - }) - ); - }); - - it('lists available providers', () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - expect(router.getProviders()).toEqual(['test-fast', 'test-quality']); - }); - - it('plans the best provider/model without executing a request', () => { - const router = new LlmRouter({ providers: TEST_PROVIDERS }); - const plan = router.plan({ - messages: [{ role: 'user', content: 'Write a TypeScript endpoint with validation' }], - }); - - expect(plan.provider.name).toBe('test-quality'); - expect(plan.model.id).toBe('quality-model'); - expect(plan.category).toBe('code'); - expect(plan.explicit).toBe(false); - }); - - it('plans local ollama models without requiring an API key', () => { - const router = new LlmRouter({ - providers: [createLocalOllamaProvider(['qwen2.5-coder:7b', 'llama3.1:8b'])], - }); - const plan = router.plan({ - messages: [{ role: 'user', content: 'Refactor this TypeScript function' }], - }); - - expect(plan.provider.name).toBe('local-ollama'); - expect(plan.model.id).toBe('qwen2.5-coder:7b'); - }); - - it('fires telemetry for explicit model routing', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: MOCK_RESPONSE, - latencyMs: 100, - status: 200, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await router.chat({ - messages: [{ role: 'user', content: 'Hello' }], - model: 'test-fast:fast-model', - }); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ - event: 'success', - provider: 'test-fast', - model: 'fast-model', - category: 'explicit', - }) - ); - }); - - it('records health on explicit model 429', async () => { - vi.mocked(client.sendChatCompletion).mockResolvedValueOnce({ - response: null as unknown as ChatCompletionResponse, - latencyMs: 50, - status: 429, - }); - - const telemetry = vi.fn(); - const router = new LlmRouter({ providers: TEST_PROVIDERS, onTelemetry: telemetry }); - await expect( - router.chat({ messages: [{ role: 'user', content: 'Hello' }], model: 'test-fast:fast-model' }) - ).rejects.toThrow('Rate limited'); - - expect(telemetry).toHaveBeenCalledWith( - expect.objectContaining({ event: 'rate_limit', provider: 'test-fast' }) - ); - - // Health should have recorded the rate limit - const health = router.getHealth(); - expect(health).toHaveLength(1); - expect(health[0]!.rateLimits).toBe(1); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts b/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts deleted file mode 100644 index 429d629..0000000 --- a/vendor/bytelyst/llm-router/src/__tests__/selector.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - selectCandidates, - pickNext, - excludeCandidate, - createRoundRobinState, -} from '../selector.js'; -import { HealthTracker } from '../health.js'; -import type { ProviderConfig } from '../types.js'; - -const MOCK_PROVIDERS: ProviderConfig[] = [ - { - name: 'fast-provider', - baseUrl: 'https://fast.example.com/v1', - apiKeyEnv: 'FAST_KEY', - rpmLimit: 30, - tpmLimit: 10_000, - models: [ - { - id: 'small-8b', - label: 'Small 8B', - contextWindow: 8_192, - strengths: ['general'], - speedTier: 1, - }, - { - id: 'large-70b', - label: 'Large 70B', - contextWindow: 128_000, - strengths: ['code', 'reasoning'], - speedTier: 1, - }, - ], - }, - { - name: 'quality-provider', - baseUrl: 'https://quality.example.com/v1', - apiKeyEnv: 'QUALITY_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'deepseek-r1', - label: 'DeepSeek R1', - contextWindow: 64_000, - strengths: ['reasoning', 'code', 'math'], - speedTier: 3, - }, - ], - }, -]; - -describe('selectCandidates', () => { - let health: HealthTracker; - - beforeEach(() => { - health = new HealthTracker(); - }); - - it('returns candidates sorted by score for code', () => { - const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); - expect(candidates.length).toBeGreaterThan(0); - // large-70b and deepseek-r1 should score high for code - const names = candidates.map(c => c.model.id); - expect(names[0]).toBe('large-70b'); // speed 1 + code strength + 70b bonus - }); - - it('returns candidates sorted by score for general', () => { - const candidates = selectCandidates(MOCK_PROVIDERS, 'general', health); - // small-8b has 'general' strength + speed tier 1 - expect(candidates[0]!.model.id).toBe('small-8b'); - }); - - it('filters out unhealthy providers', () => { - // Make fast-provider/large-70b unhealthy - for (let i = 0; i < 5; i++) { - health.record('fast-provider', 'large-70b', { - timestamp: Date.now(), - latencyMs: 100, - status: 'error', - }); - } - const candidates = selectCandidates(MOCK_PROVIDERS, 'code', health); - const ids = candidates.map(c => `${c.provider.name}::${c.model.id}`); - expect(ids).not.toContain('fast-provider::large-70b'); - }); -}); - -describe('pickNext', () => { - it('returns null for empty candidates', () => { - const state = createRoundRobinState(); - expect(pickNext([], state)).toBeNull(); - }); - - it('returns the only candidate when there is one', () => { - const state = createRoundRobinState(); - const candidate = { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }; - expect(pickNext([candidate], state)).toBe(candidate); - }); - - it('round-robins across providers', () => { - const state = createRoundRobinState(); - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - - const first = pickNext(candidates, state); - const second = pickNext(candidates, state); - expect(first!.provider.name).not.toBe(second!.provider.name); - }); - - it('uses independent state per instance', () => { - const stateA = createRoundRobinState(); - const stateB = createRoundRobinState(); - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - - const fromA = pickNext(candidates, stateA); - const fromB = pickNext(candidates, stateB); - // Both start at same position since states are independent - expect(fromA!.provider.name).toBe(fromB!.provider.name); - }); -}); - -describe('excludeCandidate', () => { - it('removes the specified candidate', () => { - const candidates = [ - { provider: MOCK_PROVIDERS[0]!, model: MOCK_PROVIDERS[0]!.models[0]! }, - { provider: MOCK_PROVIDERS[1]!, model: MOCK_PROVIDERS[1]!.models[0]! }, - ]; - const remaining = excludeCandidate(candidates, 'fast-provider', 'small-8b'); - expect(remaining).toHaveLength(1); - expect(remaining[0]!.provider.name).toBe('quality-provider'); - }); -}); diff --git a/vendor/bytelyst/llm-router/src/classifier.ts b/vendor/bytelyst/llm-router/src/classifier.ts deleted file mode 100644 index b8b5c95..0000000 --- a/vendor/bytelyst/llm-router/src/classifier.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ClassificationResult, PromptCategory } from './types.js'; - -// ── Keyword patterns for classification ──────────────────────── - -const CODE_PATTERNS = [ - /\b(function|const |let |var |class |import |export |return |async |await )\b/, - /\b(def |print\(|if __name__|lambda )\b/, - /[{}();]=>/, - /```[\s\S]*```/, - /\b(typescript|javascript|python|rust|golang|java|kotlin|swift|sql|html|css|react|node)\b/i, - /\b(debug|refactor|compile|build|deploy|lint|test|api|endpoint|route|middleware)\b/i, - /\b(fix|bug|error|exception|stack trace|undefined|null|NaN)\b/i, -]; - -const MATH_PATTERNS = [ - /\b(calculate|compute|solve|equation|formula|integral|derivative|matrix)\b/i, - /\b(probability|statistics|regression|correlation|variance|median|mean)\b/i, - /\b(algebra|geometry|calculus|theorem|proof|hypothesis)\b/i, - /[+\-*/^=]{2,}/, - /\d+\s*[+\-*/^]\s*\d+/, -]; - -const REASONING_PATTERNS = [ - /\b(explain|analyze|compare|evaluate|reason|logic|argument|conclusion)\b/i, - /\b(why|how does|what if|pros and cons|trade-?offs|implications)\b/i, - /\b(step[- ]by[- ]step|chain of thought|think through|break down)\b/i, - /\b(strategy|approach|methodology|framework|architecture|design)\b/i, -]; - -const CREATIVE_PATTERNS = [ - /\b(write|compose|draft|create|generate|story|poem|essay|blog|article)\b/i, - /\b(creative|imaginative|brainstorm|ideas|fiction|narrative|dialogue)\b/i, - /\b(rewrite|rephrase|summarize|translate|tone|style|voice)\b/i, -]; - -// ── Token estimation ─────────────────────────────────────────── - -/** - * Rough token estimate: ~4 chars per token for English text. - * Good enough for routing decisions. - */ -function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -// ── Classifier ───────────────────────────────────────────────── - -function countMatches(text: string, patterns: RegExp[]): number { - let count = 0; - for (const pattern of patterns) { - if (pattern.test(text)) count++; - } - return count; -} - -/** - * Check if messages contain image content parts (vision request). - * Handles both string content and multipart content arrays. - */ -function hasImageContent(messages: { role: string; content: string | unknown[] }[]): boolean { - for (const msg of messages) { - if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if ( - typeof part === 'object' && - part !== null && - 'type' in part && - (part as { type: string }).type === 'image_url' - ) { - return true; - } - } - } - } - return false; -} - -/** - * Classify a prompt into a category based on keyword matching. - * No LLM needed — pure regex heuristics. - * Detects vision (image) content and returns 'vision' category when present. - */ -export function classifyPrompt( - messages: { role: string; content: string | unknown[] }[] -): ClassificationResult { - // Check for vision content first — image inputs always classify as 'vision' - if (hasImageContent(messages)) { - const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); - return { category: 'vision', estimatedTokens: estimateTokens(fullText) + 1000 }; - } - - const fullText = messages.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n'); - const estimatedTokens = estimateTokens(fullText); - - const scores: Record = { - code: countMatches(fullText, CODE_PATTERNS), - math: countMatches(fullText, MATH_PATTERNS), - reasoning: countMatches(fullText, REASONING_PATTERNS), - creative: countMatches(fullText, CREATIVE_PATTERNS), - general: 1, // baseline - vision: 0, - }; - - // Pick highest scoring category - let best: PromptCategory = 'general'; - let bestScore = 0; - for (const [cat, score] of Object.entries(scores) as [PromptCategory, number][]) { - if (score > bestScore) { - bestScore = score; - best = cat; - } - } - - return { category: best, estimatedTokens }; -} diff --git a/vendor/bytelyst/llm-router/src/client.ts b/vendor/bytelyst/llm-router/src/client.ts deleted file mode 100644 index fe57769..0000000 --- a/vendor/bytelyst/llm-router/src/client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ChatCompletionRequest, ChatCompletionResponse, ProviderConfig } from './types.js'; - -/** - * Send an OpenAI-compatible chat completion request to a provider. - * Returns the parsed response or throws on HTTP/network errors. - */ -export async function sendChatCompletion( - provider: ProviderConfig, - modelId: string, - request: ChatCompletionRequest, - timeoutMs: number = 30_000 -): Promise<{ response: ChatCompletionResponse; latencyMs: number; status: number }> { - const apiKey = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : null; - if (provider.apiKeyEnv && !apiKey) { - throw new Error(`Missing API key: env var ${provider.apiKeyEnv} is not set`); - } - - const url = `${provider.baseUrl}/chat/completions`; - const headers: Record = { - 'Content-Type': 'application/json', - ...provider.extraHeaders, - }; - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - - const body = JSON.stringify({ - model: modelId, - messages: request.messages, - ...(request.temperature !== undefined && { temperature: request.temperature }), - ...(request.max_tokens !== undefined && { max_tokens: request.max_tokens }), - ...(request.top_p !== undefined && { top_p: request.top_p }), - stream: false, - }); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const start = Date.now(); - - try { - const res = await fetch(url, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - - const latencyMs = Date.now() - start; - - if (res.status === 429) { - return { - response: null as unknown as ChatCompletionResponse, - latencyMs, - status: 429, - }; - } - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${provider.name} returned ${res.status}: ${text.slice(0, 200)}`); - } - - const data = (await res.json()) as ChatCompletionResponse; - return { response: data, latencyMs, status: res.status }; - } finally { - clearTimeout(timer); - } -} diff --git a/vendor/bytelyst/llm-router/src/health.ts b/vendor/bytelyst/llm-router/src/health.ts deleted file mode 100644 index 7e1fc9c..0000000 --- a/vendor/bytelyst/llm-router/src/health.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { HealthSnapshot, RequestRecord } from './types.js'; - -/** - * Sliding-window health tracker for provider+model pairs. - * Tracks latency, error rates, and rate-limit hits. - */ -export class HealthTracker { - private records = new Map(); - private readonly windowMs: number; - private readonly errorThreshold: number; - private readonly rateLimitThreshold: number; - - constructor(opts?: { windowMs?: number; errorThreshold?: number; rateLimitThreshold?: number }) { - this.windowMs = opts?.windowMs ?? 60_000; - this.errorThreshold = opts?.errorThreshold ?? 0.5; - this.rateLimitThreshold = opts?.rateLimitThreshold ?? 0.3; - } - - private key(provider: string, model: string): string { - return `${provider}::${model}`; - } - - private prune(records: RequestRecord[]): RequestRecord[] { - const cutoff = Date.now() - this.windowMs; - return records.filter(r => r.timestamp >= cutoff); - } - - /** Record a completed request (success, rate_limit, or error). */ - record(provider: string, model: string, entry: RequestRecord): void { - const k = this.key(provider, model); - const existing = this.records.get(k) ?? []; - existing.push(entry); - this.records.set(k, this.prune(existing)); - } - - /** Get health snapshot for a provider+model pair. */ - snapshot(provider: string, model: string): HealthSnapshot { - const k = this.key(provider, model); - const raw = this.records.get(k) ?? []; - const records = this.prune(raw); - this.records.set(k, records); - - const total = records.length; - const successes = records.filter(r => r.status === 'success').length; - const rateLimits = records.filter(r => r.status === 'rate_limit').length; - const errors = records.filter(r => r.status === 'error').length; - - const successLatencies = records - .filter(r => r.status === 'success') - .map(r => r.latencyMs) - .sort((a, b) => a - b); - - const avgLatencyMs = - successLatencies.length > 0 - ? successLatencies.reduce((a, b) => a + b, 0) / successLatencies.length - : 0; - - const p95LatencyMs = - successLatencies.length > 0 - ? (successLatencies[Math.floor(successLatencies.length * 0.95)] ?? - successLatencies[successLatencies.length - 1]!) - : 0; - - // Healthy = not too many errors or rate limits - const errorRate = total > 0 ? errors / total : 0; - const rateLimitRate = total > 0 ? rateLimits / total : 0; - const healthy = - total < 3 || // not enough data → assume healthy - (errorRate < this.errorThreshold && rateLimitRate < this.rateLimitThreshold); - - return { - provider, - model, - totalRequests: total, - successes, - rateLimits, - errors, - avgLatencyMs: Math.round(avgLatencyMs), - p95LatencyMs: Math.round(p95LatencyMs), - healthy, - }; - } - - /** Check if a specific provider+model is currently healthy. */ - isHealthy(provider: string, model: string): boolean { - return this.snapshot(provider, model).healthy; - } - - /** Get all tracked snapshots. */ - allSnapshots(): HealthSnapshot[] { - const snapshots: HealthSnapshot[] = []; - for (const k of this.records.keys()) { - const [provider, model] = k.split('::') as [string, string]; - snapshots.push(this.snapshot(provider, model)); - } - return snapshots; - } - - /** Clear all tracking data. */ - reset(): void { - this.records.clear(); - } -} diff --git a/vendor/bytelyst/llm-router/src/index.ts b/vendor/bytelyst/llm-router/src/index.ts deleted file mode 100644 index 7660254..0000000 --- a/vendor/bytelyst/llm-router/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -export { LlmRouter } from './router.js'; -export type { TelemetryEntry } from './router.js'; - -export { - DEFAULT_PROVIDERS, - PAID_PROVIDERS, - createLocalOllamaProvider, - getAvailableProviders, -} from './registry.js'; -export { classifyPrompt } from './classifier.js'; -export { HealthTracker } from './health.js'; -export { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; -export type { SelectionCandidate } from './selector.js'; -export { sendChatCompletion } from './client.js'; - -export type { - ModelConfig, - ProviderConfig, - PromptCategory, - ClassificationResult, - HealthSnapshot, - RequestRecord, - RouterConfig, - ChatMessage, - ChatCompletionRequest, - ChatCompletionChoice, - ChatCompletionUsage, - ChatCompletionResponse, - RouteResult, - RoutePlan, -} from './types.js'; diff --git a/vendor/bytelyst/llm-router/src/registry.ts b/vendor/bytelyst/llm-router/src/registry.ts deleted file mode 100644 index 5c2c7d6..0000000 --- a/vendor/bytelyst/llm-router/src/registry.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; - -/** - * Paid provider configurations (opt-in via API key env vars). - * Add to your RouterConfig.providers to include alongside free-tier providers. - */ -export const PAID_PROVIDERS: ProviderConfig[] = [ - // ── OpenAI ─────────────────────────────────────────────────── - { - name: 'openai', - baseUrl: 'https://api.openai.com/v1', - apiKeyEnv: 'OPENAI_API_KEY', - rpmLimit: 500, - tpmLimit: 150_000, - models: [ - { - id: 'gpt-4o-mini', - label: 'GPT-4o Mini', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - { - id: 'gpt-4o', - label: 'GPT-4o', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code', 'creative', 'vision'], - speedTier: 2, - supportsVision: true, - }, - ], - }, - - // ── Perplexity ─────────────────────────────────────────────── - // Real-time web search grounding — OpenAI-compatible endpoint - { - name: 'perplexity', - baseUrl: 'https://api.perplexity.ai', - apiKeyEnv: 'PERPLEXITY_API_KEY', - rpmLimit: 50, - tpmLimit: 0, - models: [ - { - id: 'sonar', - label: 'Sonar (web search)', - contextWindow: 127_072, - strengths: ['general', 'reasoning'], - speedTier: 2, - }, - { - id: 'sonar-pro', - label: 'Sonar Pro (web search)', - contextWindow: 200_000, - strengths: ['general', 'reasoning'], - speedTier: 3, - }, - ], - }, -]; - -/** - * Default free-tier provider configurations. - * All use OpenAI-compatible /v1/chat/completions endpoints. - */ -export const DEFAULT_PROVIDERS: ProviderConfig[] = [ - // ── Groq ───────────────────────────────────────────────────── - // Free tier: 30 RPM, 14.4K TPM (large), 30K TPM (small) - { - name: 'groq', - baseUrl: 'https://api.groq.com/openai/v1', - apiKeyEnv: 'GROQ_API_KEY', - rpmLimit: 30, - tpmLimit: 14_400, - models: [ - { - id: 'llama-3.3-70b-versatile', - label: 'Llama 3.3 70B', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - { - id: 'llama-3.1-8b-instant', - label: 'Llama 3.1 8B Instant', - contextWindow: 128_000, - strengths: ['general'], - speedTier: 1, - }, - { - id: 'gemma2-9b-it', - label: 'Gemma 2 9B', - contextWindow: 8_192, - strengths: ['general', 'creative'], - speedTier: 1, - }, - ], - }, - - // ── OpenRouter ─────────────────────────────────────────────── - // Free models available (rate-limited per model) - { - name: 'openrouter', - baseUrl: 'https://openrouter.ai/api/v1', - apiKeyEnv: 'OPENROUTER_API_KEY', - extraHeaders: { - 'HTTP-Referer': 'https://bytelyst.com', - 'X-Title': 'ByteLyst LLM Router', - }, - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'deepseek/deepseek-r1:free', - label: 'DeepSeek R1 (Free)', - contextWindow: 64_000, - strengths: ['reasoning', 'code', 'math'], - speedTier: 3, - }, - { - id: 'meta-llama/llama-3.3-70b-instruct:free', - label: 'Llama 3.3 70B (Free)', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 2, - }, - { - id: 'google/gemma-2-9b-it:free', - label: 'Gemma 2 9B (Free)', - contextWindow: 8_192, - strengths: ['general', 'creative'], - speedTier: 2, - }, - ], - }, - - // ── Together AI ────────────────────────────────────────────── - // Free tier: limited RPM, several open models - { - name: 'together', - baseUrl: 'https://api.together.xyz/v1', - apiKeyEnv: 'TOGETHER_API_KEY', - rpmLimit: 20, - tpmLimit: 0, - models: [ - { - id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', - label: 'Llama 3.3 70B Turbo', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 2, - }, - { - id: 'deepseek-ai/DeepSeek-R1-Distill-Llama-70B', - label: 'DeepSeek R1 Distill 70B', - contextWindow: 128_000, - strengths: ['reasoning', 'math', 'code'], - speedTier: 2, - }, - ], - }, - - // ── Cerebras ───────────────────────────────────────────────── - // Free inference tier — extremely fast - { - name: 'cerebras', - baseUrl: 'https://api.cerebras.ai/v1', - apiKeyEnv: 'CEREBRAS_API_KEY', - rpmLimit: 30, - tpmLimit: 60_000, - models: [ - { - id: 'llama-3.3-70b', - label: 'Llama 3.3 70B (Cerebras)', - contextWindow: 128_000, - strengths: ['general', 'reasoning', 'code'], - speedTier: 1, - }, - ], - }, -]; - -function inferStrengths(modelId: string): PromptCategory[] { - const lower = modelId.toLowerCase(); - const strengths = new Set(['general']); - - if (/coder|code|codestral|starcoder|deepseek/.test(lower)) strengths.add('code'); - if (/r1|reason|think|math/.test(lower)) { - strengths.add('reasoning'); - strengths.add('math'); - } - if (/qwen|llama|mistral|chat/.test(lower)) strengths.add('creative'); - - return [...strengths]; -} - -function inferContextWindow(modelId: string): number { - const lower = modelId.toLowerCase(); - if (/128k|131072/.test(lower)) return 128_000; - if (/64k|65536/.test(lower)) return 64_000; - if (/32k|32768|qwen2\.5/.test(lower)) return 32_768; - if (/16k|16384/.test(lower)) return 16_384; - return 8_192; -} - -function inferSpeedTier(modelId: string): 1 | 2 | 3 { - const lower = modelId.toLowerCase(); - if (/0\.5b|1b|3b|7b|mini|tiny/.test(lower)) return 1; - if (/14b|15b|16b|20b|22b|30b|32b/.test(lower)) return 2; - return 3; -} - -export function createLocalOllamaProvider( - modelIds: string[], - baseUrl: string = 'http://localhost:11434/v1' -): ProviderConfig { - const models: ModelConfig[] = modelIds.map(modelId => ({ - id: modelId, - label: modelId, - contextWindow: inferContextWindow(modelId), - strengths: inferStrengths(modelId), - speedTier: inferSpeedTier(modelId), - })); - - return { - name: 'local-ollama', - baseUrl, - models, - rpmLimit: 0, - tpmLimit: 0, - }; -} - -/** - * Filter providers to only those with API keys present in env. - */ -export function getAvailableProviders( - providers: ProviderConfig[] = DEFAULT_PROVIDERS -): ProviderConfig[] { - return providers.filter(p => { - if (!p.apiKeyEnv) return true; - const key = process.env[p.apiKeyEnv]; - return key !== undefined && key !== ''; - }); -} diff --git a/vendor/bytelyst/llm-router/src/router.ts b/vendor/bytelyst/llm-router/src/router.ts deleted file mode 100644 index f46951d..0000000 --- a/vendor/bytelyst/llm-router/src/router.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { - ChatCompletionRequest, - PromptCategory, - RouterConfig, - ProviderConfig, - RouteResult, - RoutePlan, - HealthSnapshot, -} from './types.js'; -import { DEFAULT_PROVIDERS, getAvailableProviders } from './registry.js'; -import { classifyPrompt } from './classifier.js'; -import { HealthTracker } from './health.js'; -import { selectCandidates, pickNext, excludeCandidate, createRoundRobinState } from './selector.js'; -import { sendChatCompletion } from './client.js'; - -export class LlmRouter { - private readonly providers: ProviderConfig[]; - private readonly health: HealthTracker; - private readonly timeoutMs: number; - private readonly maxRetries: number; - private readonly log: (entry: TelemetryEntry) => void; - private readonly roundRobinState: Map; - - constructor(config?: RouterConfig & { onTelemetry?: (entry: TelemetryEntry) => void }) { - const allProviders = config?.providers ?? DEFAULT_PROVIDERS; - this.providers = getAvailableProviders(allProviders); - - if (this.providers.length === 0) { - throw new Error( - 'No providers available. Set at least one API key env var: ' + - allProviders.map(p => p.apiKeyEnv).join(', ') - ); - } - - this.health = new HealthTracker({ - windowMs: config?.healthWindowMs, - errorThreshold: config?.errorThreshold, - rateLimitThreshold: config?.rateLimitThreshold, - }); - - this.timeoutMs = config?.timeoutMs ?? 30_000; - this.maxRetries = config?.maxRetries ?? 3; - this.log = config?.onTelemetry ?? (() => {}); - this.roundRobinState = createRoundRobinState(); - } - - /** - * Route a chat completion request to the best available provider. - * Automatically retries on 429/5xx with fallback to other providers. - */ - async chat(request: ChatCompletionRequest): Promise { - const startTime = Date.now(); - - const plan = this.planInternal(request, false); - - if (plan.explicit) { - return this.chatWithExplicitModel(request, startTime, plan); - } - - const category = plan.category as PromptCategory; - let candidates = selectCandidates(this.providers, category, this.health); - - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= this.maxRetries; attempt++) { - const pick = pickNext(candidates, this.roundRobinState); - if (!pick) break; - - const { provider, model } = pick; - const attemptStart = Date.now(); - - try { - const result = await sendChatCompletion(provider, model.id, request, this.timeoutMs); - - if (result.status === 429) { - // Rate limited — record and try next provider - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'rate_limit', - }); - - this.log({ - event: 'rate_limit', - provider: provider.name, - model: model.id, - attempt, - latencyMs: result.latencyMs, - category, - }); - - candidates = excludeCandidate(candidates, provider.name, model.id); - continue; - } - - // Success - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'success', - }); - - this.log({ - event: 'success', - provider: provider.name, - model: model.id, - attempt, - latencyMs: result.latencyMs, - category, - tokens: result.response.usage?.total_tokens, - }); - - return { - response: result.response, - provider: provider.name, - model: model.id, - totalLatencyMs: Date.now() - startTime, - attempts: attempt, - }; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - const attemptLatency = Date.now() - attemptStart; - - this.health.record(provider.name, model.id, { - timestamp: Date.now(), - latencyMs: attemptLatency, - status: 'error', - }); - - this.log({ - event: 'error', - provider: provider.name, - model: model.id, - attempt, - latencyMs: attemptLatency, - category, - error: lastError.message, - }); - - candidates = excludeCandidate(candidates, provider.name, model.id); - } - } - - throw new Error( - `All providers exhausted after ${this.maxRetries} attempts. Last error: ${lastError?.message ?? 'unknown'}` - ); - } - - /** - * Handle explicit provider:model routing (bypass classifier). - */ - private async chatWithExplicitModel( - request: ChatCompletionRequest, - startTime: number, - plan?: RoutePlan - ): Promise { - const resolved = plan ?? this.plan(request); - const provider = resolved.provider; - const modelId = resolved.model.id; - - try { - const result = await sendChatCompletion(provider, modelId, request, this.timeoutMs); - - if (result.status === 429) { - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'rate_limit', - }); - - this.log({ - event: 'rate_limit', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: result.latencyMs, - category: 'explicit', - }); - - throw new Error(`Rate limited by ${provider.name} for model ${modelId}`); - } - - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: result.latencyMs, - status: 'success', - }); - - this.log({ - event: 'success', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: result.latencyMs, - category: 'explicit', - tokens: result.response.usage?.total_tokens, - }); - - return { - response: result.response, - provider: provider.name, - model: modelId, - totalLatencyMs: Date.now() - startTime, - attempts: 1, - }; - } catch (err) { - // Re-throw rate-limit errors (already logged above) - if (err instanceof Error && err.message.startsWith('Rate limited by')) { - throw err; - } - - const latency = Date.now() - startTime; - this.health.record(provider.name, modelId, { - timestamp: Date.now(), - latencyMs: latency, - status: 'error', - }); - - this.log({ - event: 'error', - provider: provider.name, - model: modelId, - attempt: 1, - latencyMs: latency, - category: 'explicit', - error: err instanceof Error ? err.message : String(err), - }); - - throw err; - } - } - - plan(request: ChatCompletionRequest): RoutePlan { - return this.planInternal(request, true); - } - - private planInternal(request: ChatCompletionRequest, advanceRoundRobin: boolean): RoutePlan { - const explicit = this.resolveExplicitModel(request.model); - if (explicit) { - return { - provider: explicit.provider, - model: explicit.model, - category: 'explicit', - explicit: true, - }; - } - - const classification = classifyPrompt(request.messages); - const candidates = selectCandidates(this.providers, classification.category, this.health); - - if (candidates.length === 0) { - throw new Error('No healthy providers available for routing'); - } - - const pick = advanceRoundRobin - ? pickNext(candidates, this.roundRobinState) - : (candidates[0] ?? null); - if (!pick) { - throw new Error('No provider available for routing'); - } - - return { - provider: pick.provider, - model: pick.model, - category: classification.category, - explicit: false, - }; - } - - private resolveExplicitModel( - model?: string - ): { provider: ProviderConfig; model: RoutePlan['model'] } | null { - if (!model) return null; - - if (model.includes(':') || model.includes('/')) { - const { providerName, modelId } = parseExplicitModel(model); - const provider = this.providers.find(p => p.name === providerName); - if (!provider) { - throw new Error( - `Provider "${providerName}" not found. Available: ${this.providers.map(p => p.name).join(', ')}` - ); - } - - const matchedModel = provider.models.find(candidate => candidate.id === modelId); - if (!matchedModel) { - throw new Error( - `Model "${modelId}" not found for provider "${providerName}". Available: ${provider.models - .map(candidate => candidate.id) - .join(', ')}` - ); - } - - return { provider, model: matchedModel }; - } - - const matches = this.providers.flatMap(provider => - provider.models - .filter(candidate => candidate.id === model) - .map(candidate => ({ provider, model: candidate })) - ); - - if (matches.length === 1) { - return matches[0]!; - } - - if (matches.length > 1) { - throw new Error( - `Model "${model}" is available on multiple providers. Use provider:model format instead.` - ); - } - - return null; - } - - /** Get health snapshots for all tracked provider+model pairs. */ - getHealth(): HealthSnapshot[] { - return this.health.allSnapshots(); - } - - /** Get list of available (configured) providers. */ - getProviders(): string[] { - return this.providers.map(p => p.name); - } - - /** Reset health tracking data. */ - resetHealth(): void { - this.health.reset(); - } -} - -function parseExplicitModel(raw: string): { providerName: string; modelId: string } { - const colonIdx = raw.indexOf(':'); - const slashIdx = raw.indexOf('/'); - let sepIdx: number; - if (colonIdx === -1 && slashIdx === -1) { - sepIdx = -1; - } else if (colonIdx === -1) { - sepIdx = slashIdx; - } else if (slashIdx === -1) { - sepIdx = colonIdx; - } else { - sepIdx = Math.min(colonIdx, slashIdx); - } - - return { - providerName: sepIdx === -1 ? raw : raw.slice(0, sepIdx), - modelId: sepIdx === -1 ? '' : raw.slice(sepIdx + 1), - }; -} - -// ── Telemetry types ──────────────────────────────────────────── - -export interface TelemetryEntry { - event: 'success' | 'rate_limit' | 'error'; - provider: string; - model: string; - attempt: number; - latencyMs: number; - category: string; - tokens?: number; - error?: string; -} diff --git a/vendor/bytelyst/llm-router/src/selector.ts b/vendor/bytelyst/llm-router/src/selector.ts deleted file mode 100644 index 48c3821..0000000 --- a/vendor/bytelyst/llm-router/src/selector.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { ModelConfig, PromptCategory, ProviderConfig } from './types.js'; -import type { HealthTracker } from './health.js'; - -export interface SelectionCandidate { - provider: ProviderConfig; - model: ModelConfig; -} - -/** Create a fresh round-robin state map (one per router instance). */ -export function createRoundRobinState(): Map { - return new Map(); -} - -/** - * Score a model for a given prompt category. - * Higher = better fit. - */ -function scoreModel(model: ModelConfig, category: PromptCategory): number { - let score = 0; - - // Vision requests require vision-capable models - if (category === 'vision') { - if (!model.supportsVision) return -1; // Exclude non-vision models - score += 15; // Strong boost for vision capability - } - - // Direct strength match is the strongest signal - if (model.strengths.includes(category)) { - score += 10; - } - - // Speed bonus (lower tier = faster = better for simple tasks) - score += (4 - model.speedTier) * 2; - - // Context window bonus for reasoning/creative (often longer) - if ((category === 'reasoning' || category === 'creative') && model.contextWindow >= 64_000) { - score += 3; - } - - // Prefer larger models for code/math/reasoning - if (['code', 'math', 'reasoning'].includes(category)) { - if (model.id.includes('70b') || model.id.includes('70B')) score += 5; - if (model.id.includes('r1') || model.id.includes('R1')) score += 4; - } - - return score; -} - -/** - * Select the best provider+model candidates for a prompt category. - * Returns candidates sorted by score (best first), filtered by health. - */ -export function selectCandidates( - providers: ProviderConfig[], - category: PromptCategory, - health: HealthTracker -): SelectionCandidate[] { - const candidates: (SelectionCandidate & { score: number })[] = []; - - for (const provider of providers) { - for (const model of provider.models) { - if (!health.isHealthy(provider.name, model.id)) continue; - - const score = scoreModel(model, category); - if (score < 0) continue; // Skip incompatible models (e.g. non-vision for vision requests) - candidates.push({ provider, model, score }); - } - } - - // Sort by score descending - candidates.sort((a, b) => b.score - a.score); - - return candidates; -} - -/** - * Pick the next candidate using round-robin within the top tier. - * Groups candidates by provider, rotates between them to spread rate-limit load. - */ -export function pickNext( - candidates: SelectionCandidate[], - state: Map -): SelectionCandidate | null { - if (candidates.length === 0) return null; - if (candidates.length === 1) return candidates[0]!; - - // Group by provider name for round-robin - const providerNames = [...new Set(candidates.map(c => c.provider.name))]; - const key = providerNames.join(','); - - const idx = state.get(key) ?? 0; - const targetProvider = providerNames[idx % providerNames.length]!; - state.set(key, idx + 1); - - // Pick the best model from the selected provider - return candidates.find(c => c.provider.name === targetProvider) ?? candidates[0]!; -} - -/** - * Remove a candidate from the list (after failure) and return remaining. - */ -export function excludeCandidate( - candidates: SelectionCandidate[], - provider: string, - model: string -): SelectionCandidate[] { - return candidates.filter(c => !(c.provider.name === provider && c.model.id === model)); -} diff --git a/vendor/bytelyst/llm-router/src/types.ts b/vendor/bytelyst/llm-router/src/types.ts deleted file mode 100644 index 4831291..0000000 --- a/vendor/bytelyst/llm-router/src/types.ts +++ /dev/null @@ -1,147 +0,0 @@ -// ── Provider & Model Types ───────────────────────────────────── - -export interface ModelConfig { - /** Model identifier as the provider expects it */ - id: string; - /** Human-readable label */ - label: string; - /** Max context window tokens */ - contextWindow: number; - /** What this model is good at */ - strengths: PromptCategory[]; - /** Relative speed tier: 1 = fastest, 3 = slowest */ - speedTier: 1 | 2 | 3; - /** Whether the model supports vision (image) inputs */ - supportsVision?: boolean; - /** Whether the model supports text embedding generation */ - supportsEmbedding?: boolean; -} - -export interface ProviderConfig { - /** Unique provider name */ - name: string; - /** OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1) */ - baseUrl: string; - /** Environment variable name that holds the API key (omit for local/no-auth providers) */ - apiKeyEnv?: string; - /** Available models on this provider */ - models: ModelConfig[]; - /** Extra headers to send with every request */ - extraHeaders?: Record; - /** Free-tier rate limit: requests per minute (0 = unknown) */ - rpmLimit: number; - /** Free-tier rate limit: tokens per minute (0 = unknown) */ - tpmLimit: number; -} - -// ── Prompt Classification ────────────────────────────────────── - -export type PromptCategory = 'code' | 'math' | 'reasoning' | 'creative' | 'general' | 'vision'; - -export interface ClassificationResult { - category: PromptCategory; - estimatedTokens: number; -} - -// ── Health Tracking ──────────────────────────────────────────── - -export interface HealthSnapshot { - provider: string; - model: string; - /** Total requests in the window */ - totalRequests: number; - /** Successful requests */ - successes: number; - /** 429 rate-limit hits */ - rateLimits: number; - /** 5xx / network errors */ - errors: number; - /** Average latency in ms (successes only) */ - avgLatencyMs: number; - /** p95 latency in ms */ - p95LatencyMs: number; - /** Whether this provider is currently considered healthy */ - healthy: boolean; -} - -export interface RequestRecord { - timestamp: number; - latencyMs: number; - status: 'success' | 'rate_limit' | 'error'; -} - -// ── Router Config ────────────────────────────────────────────── - -export interface RouterConfig { - /** Provider configurations (use DEFAULT_PROVIDERS if omitted) */ - providers?: ProviderConfig[]; - /** Health window in ms (default: 60_000 = 1 minute) */ - healthWindowMs?: number; - /** Error rate threshold to mark unhealthy (default: 0.5 = 50%) */ - errorThreshold?: number; - /** Rate-limit rate threshold to mark unhealthy (default: 0.3 = 30%) */ - rateLimitThreshold?: number; - /** Request timeout in ms (default: 30_000) */ - timeoutMs?: number; - /** Max retry attempts across providers (default: 3) */ - maxRetries?: number; -} - -// ── OpenAI-Compatible Request/Response ───────────────────────── - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -export interface ChatCompletionRequest { - messages: ChatMessage[]; - /** Optional: force a specific model (provider:model format or just model id) */ - model?: string; - temperature?: number; - max_tokens?: number; - top_p?: number; - stream?: boolean; -} - -export interface ChatCompletionChoice { - index: number; - message: ChatMessage; - finish_reason: string | null; -} - -export interface ChatCompletionUsage { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; -} - -export interface ChatCompletionResponse { - id: string; - object: 'chat.completion'; - created: number; - model: string; - choices: ChatCompletionChoice[]; - usage?: ChatCompletionUsage; -} - -// ── Router Result (wraps response + metadata) ────────────────── - -export interface RouteResult { - response: ChatCompletionResponse; - /** Which provider served this request */ - provider: string; - /** Which model was used */ - model: string; - /** Total latency in ms (including retries) */ - totalLatencyMs: number; - /** How many attempts were made */ - attempts: number; -} - -export interface RoutePlan { - provider: ProviderConfig; - model: ModelConfig; - category: PromptCategory | 'explicit'; - explicit: boolean; -} diff --git a/vendor/bytelyst/llm-router/tsconfig.json b/vendor/bytelyst/llm-router/tsconfig.json deleted file mode 100644 index 8635ab2..0000000 --- a/vendor/bytelyst/llm-router/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/llm-router/vitest.config.ts b/vendor/bytelyst/llm-router/vitest.config.ts deleted file mode 100644 index 811c18a..0000000 --- a/vendor/bytelyst/llm-router/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/llm/package.json b/vendor/bytelyst/llm/package.json deleted file mode 100644 index 215c0c1..0000000 --- a/vendor/bytelyst/llm/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/llm", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/llm/src/__tests__/fallback.test.ts b/vendor/bytelyst/llm/src/__tests__/fallback.test.ts deleted file mode 100644 index 89d4152..0000000 --- a/vendor/bytelyst/llm/src/__tests__/fallback.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Tests for createFallbackChain. - */ - -import { describe, it, expect } from 'vitest'; -import { createFallbackChain } from '../fallback.js'; -import { MockLLMProvider } from '../providers/mock.js'; -import type { ChatCompletionResponse } from '../types.js'; - -const makeResponse = (content: string): ChatCompletionResponse => ({ - content, - model: 'mock', - finishReason: 'stop', - usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, -}); - -describe('createFallbackChain', () => { - it('isConfigured returns true when at least one provider is configured', () => { - const a = new MockLLMProvider(); - const chain = createFallbackChain([a]); - expect(chain.isConfigured()).toBe(true); - }); - - it('isConfigured returns false when no providers are configured', () => { - const unconfigured = { - isConfigured: () => false, - chatCompletion: async () => { - throw new Error('not configured'); - }, - }; - const chain = createFallbackChain([unconfigured]); - expect(chain.isConfigured()).toBe(false); - }); - - it('returns response from first configured provider', async () => { - const a = new MockLLMProvider([makeResponse('from-a')]); - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([a, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-a'); - expect(b.calls).toHaveLength(0); - }); - - it('falls back to second provider when first throws', async () => { - const a = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('a failed'); - }, - }; - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([a, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-b'); - }); - - it('skips unconfigured providers', async () => { - const unconfigured = { - isConfigured: () => false, - chatCompletion: async (): Promise => { - throw new Error('should not be called'); - }, - }; - const b = new MockLLMProvider([makeResponse('from-b')]); - const chain = createFallbackChain([unconfigured, b]); - - const result = await chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.content).toBe('from-b'); - }); - - it('throws with all error messages when every provider fails', async () => { - const a = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('a failed'); - }, - }; - const b = { - isConfigured: () => true, - chatCompletion: async (): Promise => { - throw new Error('b failed'); - }, - }; - const chain = createFallbackChain([a, b]); - - await expect( - chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) - ).rejects.toThrow('All providers failed: a failed | b failed'); - }); - - it('throws "No providers configured" when list is empty', async () => { - const chain = createFallbackChain([]); - await expect( - chain.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }) - ).rejects.toThrow('No providers configured'); - }); -}); diff --git a/vendor/bytelyst/llm/src/__tests__/llm.test.ts b/vendor/bytelyst/llm/src/__tests__/llm.test.ts deleted file mode 100644 index 8db6b8d..0000000 --- a/vendor/bytelyst/llm/src/__tests__/llm.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Tests for LLM providers, factory, types, and helpers. - */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { MockLLMProvider } from '../providers/mock.js'; -import { createLLMProvider, _resetLLM } from '../factory.js'; -import { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from '../types.js'; -import type { ChatMessage, ChatCompletionRequest, EmbeddingResponse } from '../types.js'; - -// ── Helper function tests ───────────────────────────────────────── - -describe('isVisionMessage', () => { - it('returns false for string content', () => { - const msg: ChatMessage = { role: 'user', content: 'hello' }; - expect(isVisionMessage(msg)).toBe(false); - }); - - it('returns false for text-only multipart', () => { - const msg: ChatMessage = { - role: 'user', - content: [{ type: 'text', text: 'hello' }], - }; - expect(isVisionMessage(msg)).toBe(false); - }); - - it('returns true when message contains image_url part', () => { - const msg: ChatMessage = { - role: 'user', - content: [ - { type: 'text', text: 'describe this' }, - { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, - ], - }; - expect(isVisionMessage(msg)).toBe(true); - }); -}); - -describe('hasVisionContent', () => { - it('returns false for text-only request', () => { - const req: ChatCompletionRequest = { - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'hello' }, - ], - }; - expect(hasVisionContent(req)).toBe(false); - }); - - it('returns true when any message has image content', () => { - const req: ChatCompletionRequest = { - messages: [ - { role: 'system', content: 'You are helpful' }, - { - role: 'user', - content: [ - { type: 'text', text: 'what is this?' }, - { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }, - ], - }, - ], - }; - expect(hasVisionContent(req)).toBe(true); - }); -}); - -describe('buildVisionMessage', () => { - it('builds a multipart user message with text and image', () => { - const msg = buildVisionMessage('Describe this', 'https://img.com/a.png'); - expect(msg.role).toBe('user'); - expect(Array.isArray(msg.content)).toBe(true); - const parts = msg.content as Array<{ type: string }>; - expect(parts).toHaveLength(2); - expect(parts[0]).toEqual({ type: 'text', text: 'Describe this' }); - expect(parts[1]).toEqual({ - type: 'image_url', - image_url: { url: 'https://img.com/a.png', detail: 'auto' }, - }); - }); - - it('respects detail parameter', () => { - const msg = buildVisionMessage('hi', 'https://img.com/b.png', 'high'); - const parts = msg.content as Array<{ type: string; image_url?: { detail: string } }>; - expect(parts[1]?.image_url?.detail).toBe('high'); - }); -}); - -describe('getMessageText', () => { - it('returns string content directly', () => { - expect(getMessageText({ role: 'user', content: 'hello' })).toBe('hello'); - }); - - it('extracts text from multipart content', () => { - const msg: ChatMessage = { - role: 'user', - content: [ - { type: 'text', text: 'line one' }, - { type: 'image_url', image_url: { url: 'https://img.com/x.png' } }, - { type: 'text', text: 'line two' }, - ], - }; - expect(getMessageText(msg)).toBe('line one\nline two'); - }); - - it('returns empty for image-only multipart', () => { - const msg: ChatMessage = { - role: 'user', - content: [{ type: 'image_url', image_url: { url: 'https://img.com/x.png' } }], - }; - expect(getMessageText(msg)).toBe(''); - }); -}); - -// ── MockLLMProvider tests ───────────────────────────────────────── - -describe('MockLLMProvider', () => { - let provider: MockLLMProvider; - - beforeEach(() => { - provider = new MockLLMProvider(); - }); - - it('isConfigured returns true', () => { - expect(provider.isConfigured()).toBe(true); - }); - - it('returns default echo response', async () => { - const result = await provider.chatCompletion({ - messages: [{ role: 'user', content: 'Hello' }], - }); - expect(result.content).toContain('Hello'); - expect(result.finishReason).toBe('stop'); - }); - - it('returns queued responses', async () => { - provider.addResponse({ - content: 'Custom response', - model: 'test-model', - finishReason: 'stop', - usage: { promptTokens: 5, completionTokens: 5, totalTokens: 10 }, - }); - - const result = await provider.chatCompletion({ - messages: [{ role: 'user', content: 'Hello' }], - }); - expect(result.content).toBe('Custom response'); - }); - - it('tracks calls', async () => { - const req = { messages: [{ role: 'user' as const, content: 'Test' }] }; - await provider.chatCompletion(req); - expect(provider.calls).toHaveLength(1); - expect(provider.calls[0]).toEqual(req); - }); - - it('reset clears calls and responses', async () => { - provider.addResponse({ - content: 'x', - model: 'm', - finishReason: 'stop', - usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, - }); - await provider.chatCompletion({ messages: [{ role: 'user', content: 'Test' }] }); - provider.reset(); - expect(provider.calls).toHaveLength(0); - }); - - it('handles multipart (vision) content in echo response', async () => { - const result = await provider.chatCompletion({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Describe this image' }, - { type: 'image_url', image_url: { url: 'https://example.com/img.png' } }, - ], - }, - ], - }); - expect(result.content).toContain('Describe this image'); - }); - - // ── Streaming tests ────────────────────────────────── - - it('streams default echo response word by word', async () => { - const stream = provider.chatCompletionStream!({ - messages: [{ role: 'user', content: 'Hi' }], - }); - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - expect(chunks.length).toBeGreaterThan(0); - const full = chunks.join(''); - expect(full).toContain('Hi'); - }); - - it('streams queued response word by word', async () => { - provider.addResponse({ - content: 'Hello World', - model: 'test', - finishReason: 'stop', - usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, - }); - const stream = provider.chatCompletionStream!({ - messages: [{ role: 'user', content: 'x' }], - }); - const chunks: string[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - expect(chunks).toEqual(['Hello ', 'World ']); - }); - - it('streaming tracks calls', async () => { - const req = { messages: [{ role: 'user' as const, content: 'stream test' }] }; - const stream = provider.chatCompletionStream!(req); - const drained: string[] = []; - for await (const chunk of stream) { - drained.push(chunk); - } - expect(drained.length).toBeGreaterThan(0); - expect(provider.calls).toHaveLength(1); - }); - - // ── Embedding tests ────────────────────────────────── - - it('returns deterministic embeddings for single input', async () => { - const result = await provider.embed!({ input: 'hello world' }); - expect(result.embeddings).toHaveLength(1); - expect(result.embeddings[0].length).toBe(8); - expect(result.model).toBe('mock-embedding-model'); - // Verify normalized (magnitude ≈ 1) - const mag = Math.sqrt(result.embeddings[0].reduce((s, v) => s + v * v, 0)); - expect(mag).toBeCloseTo(1.0, 3); - }); - - it('returns multiple embeddings for array input', async () => { - const result = await provider.embed!({ input: ['hello', 'world'] }); - expect(result.embeddings).toHaveLength(2); - expect(result.embeddings[0]).not.toEqual(result.embeddings[1]); - }); - - it('returns queued embedding response', async () => { - const custom: EmbeddingResponse = { - embeddings: [[0.1, 0.2, 0.3]], - model: 'custom-embed', - usage: { promptTokens: 1, completionTokens: 0, totalTokens: 1 }, - }; - provider.addEmbeddingResponse(custom); - const result = await provider.embed!({ input: 'test' }); - expect(result).toEqual(custom); - }); - - it('tracks embed calls', async () => { - await provider.embed!({ input: 'track me' }); - expect(provider.embedCalls).toHaveLength(1); - expect(provider.embedCalls[0].input).toBe('track me'); - }); - - it('deterministic: same input produces same embedding', async () => { - const r1 = await provider.embed!({ input: 'identical text' }); - const r2 = await provider.embed!({ input: 'identical text' }); - expect(r1.embeddings[0]).toEqual(r2.embeddings[0]); - }); - - it('reset clears embed state', async () => { - const custom: EmbeddingResponse = { - embeddings: [[0.5]], - model: 'm', - usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, - }; - provider.addEmbeddingResponse(custom); - await provider.embed!({ input: 'test' }); - provider.reset(); - expect(provider.embedCalls).toHaveLength(0); - // After reset, embed should return default (not queued) response - const result = await provider.embed!({ input: 'after reset' }); - expect(result.model).toBe('mock-embedding-model'); - }); -}); - -// ── Factory tests ───────────────────────────────────────────────── - -describe('createLLMProvider', () => { - afterEach(() => { - _resetLLM(); - vi.unstubAllEnvs(); - }); - - it('creates mock provider', () => { - const provider = createLLMProvider('mock'); - expect(provider).toBeInstanceOf(MockLLMProvider); - }); - - it('throws for unknown type', () => { - expect(() => createLLMProvider('unknown' as 'mock')).toThrow('Unknown LLM_PROVIDER'); - }); -}); diff --git a/vendor/bytelyst/llm/src/__tests__/providers.test.ts b/vendor/bytelyst/llm/src/__tests__/providers.test.ts deleted file mode 100644 index 7ba1510..0000000 --- a/vendor/bytelyst/llm/src/__tests__/providers.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Tests for PerplexityProvider and GeminiProvider. - * Uses vi.stubGlobal to mock fetch — no real API calls. - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { PerplexityProvider } from '../providers/perplexity.js'; -import { GeminiProvider } from '../providers/gemini.js'; - -const makeOpenAIResponse = (content: string, model = 'test-model') => ({ - choices: [{ message: { content }, finish_reason: 'stop' }], - model, - usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, -}); - -const makeGeminiResponse = (text: string) => ({ - candidates: [{ content: { parts: [{ text }] }, finishReason: 'STOP' }], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10, totalTokenCount: 15 }, -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); -}); - -// ── PerplexityProvider ────────────────────────────────────────── - -describe('PerplexityProvider', () => { - it('isConfigured false without API key', () => { - const p = new PerplexityProvider({ apiKey: '' }); - expect(p.isConfigured()).toBe(false); - }); - - it('isConfigured true with API key', () => { - const p = new PerplexityProvider({ apiKey: 'test-key' }); - expect(p.isConfigured()).toBe(true); - }); - - it('reads apiKey from env', () => { - vi.stubEnv('PERPLEXITY_API_KEY', 'env-key'); - const p = new PerplexityProvider(); - expect(p.isConfigured()).toBe(true); - }); - - it('throws when not configured', async () => { - const p = new PerplexityProvider({ apiKey: '' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Perplexity is not configured' - ); - }); - - it('calls Perplexity API and maps response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => makeOpenAIResponse('analysis result', 'sonar'), - }); - vi.stubGlobal('fetch', fetchMock); - - const p = new PerplexityProvider({ apiKey: 'test-key', model: 'sonar' }); - const result = await p.chatCompletion({ - messages: [{ role: 'user', content: 'analyse BTC' }], - temperature: 0.2, - }); - - expect(result.content).toBe('analysis result'); - expect(result.model).toBe('sonar'); - expect(result.finishReason).toBe('stop'); - expect(result.usage.totalTokens).toBe(15); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://api.perplexity.ai/chat/completions'); - expect((init.headers as Record)['Authorization']).toBe('Bearer test-key'); - }); - - it('throws on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 429, - text: async () => 'rate limited', - }) - ); - - const p = new PerplexityProvider({ apiKey: 'test-key' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Perplexity error 429' - ); - }); -}); - -// ── GeminiProvider ────────────────────────────────────────────── - -describe('GeminiProvider', () => { - it('isConfigured false without API key', () => { - const p = new GeminiProvider({ apiKey: '' }); - expect(p.isConfigured()).toBe(false); - }); - - it('isConfigured true with API key', () => { - const p = new GeminiProvider({ apiKey: 'test-key' }); - expect(p.isConfigured()).toBe(true); - }); - - it('reads apiKey from env', () => { - vi.stubEnv('GEMINI_API_KEY', 'env-key'); - const p = new GeminiProvider(); - expect(p.isConfigured()).toBe(true); - }); - - it('throws when not configured', async () => { - const p = new GeminiProvider({ apiKey: '' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Gemini is not configured' - ); - }); - - it('calls Gemini API and maps response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => makeGeminiResponse('gemini analysis'), - }); - vi.stubGlobal('fetch', fetchMock); - - const p = new GeminiProvider({ apiKey: 'test-key', model: 'gemini-1.5-flash' }); - const result = await p.chatCompletion({ - messages: [ - { role: 'system', content: 'You are a trading assistant.' }, - { role: 'user', content: 'analyse BTC' }, - ], - temperature: 0.2, - }); - - expect(result.content).toBe('gemini analysis'); - expect(result.model).toBe('gemini-1.5-flash'); - expect(result.finishReason).toBe('stop'); - expect(result.usage.totalTokens).toBe(15); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain('generativelanguage.googleapis.com'); - expect(url).toContain('gemini-1.5-flash'); - expect(url).toContain('test-key'); - - const body = JSON.parse(init.body as string); - expect(body.systemInstruction.parts[0].text).toBe('You are a trading assistant.'); - expect(body.contents[0].role).toBe('user'); - }); - - it('maps MAX_TOKENS finish reason to length', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [{ content: { parts: [{ text: 'truncated' }] }, finishReason: 'MAX_TOKENS' }], - usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 }, - }), - }) - ); - - const p = new GeminiProvider({ apiKey: 'test-key' }); - const result = await p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] }); - expect(result.finishReason).toBe('length'); - }); - - it('throws on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 400, - text: async () => 'bad request', - }) - ); - - const p = new GeminiProvider({ apiKey: 'test-key' }); - await expect(p.chatCompletion({ messages: [{ role: 'user', content: 'hi' }] })).rejects.toThrow( - 'Gemini error 400' - ); - }); -}); diff --git a/vendor/bytelyst/llm/src/factory.ts b/vendor/bytelyst/llm/src/factory.ts deleted file mode 100644 index 3765167..0000000 --- a/vendor/bytelyst/llm/src/factory.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * LLM provider factory. - * - * Creates an LLMProvider based on LLM_PROVIDER env var. - * Auto-detects provider from endpoint/key env vars if not explicitly set. - * - * Provider selection priority: - * LLM_PROVIDER env var > auto-detect from endpoint/key env vars > openai - * - * To use a fallback chain (e.g. perplexity → openai → gemini), set: - * LLM_PROVIDER=fallback - * LLM_FALLBACK_ORDER=perplexity,openai,gemini (default if unset) - */ - -import { AzureOpenAIProvider } from './providers/azure-openai.js'; -import { FallbackLLMProvider } from './providers/fallback.js'; -import { GeminiProvider } from './providers/gemini.js'; -import { MockLLMProvider } from './providers/mock.js'; -import { OpenAIProvider } from './providers/openai.js'; -import { PerplexityProvider } from './providers/perplexity.js'; -import type { LLMProvider, LLMProviderType } from './types.js'; - -let _provider: LLMProvider | null = null; - -/** - * Resolve provider type from env vars. - * Priority: LLM_PROVIDER > OPENAI_PROVIDER > auto-detect from keys/endpoints. - */ -function resolveProviderType(): LLMProviderType { - const explicit = (process.env.LLM_PROVIDER || process.env.OPENAI_PROVIDER || '').toLowerCase(); - if (explicit === 'azure') return 'azure'; - if (explicit === 'openai') return 'openai'; - if (explicit === 'perplexity') return 'perplexity'; - if (explicit === 'gemini') return 'gemini'; - if (explicit === 'fallback') return 'fallback'; - if (explicit === 'mock') return 'mock'; - - // Auto-detect from environment - const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT ?? ''; - const baseUrl = process.env.OPENAI_BASE_URL ?? ''; - if (azureEndpoint.trim().length > 0) return 'azure'; - if (baseUrl.includes('.cognitive.microsoft.com') || baseUrl.includes('.openai.azure.com')) - return 'azure'; - if (process.env.PERPLEXITY_API_KEY) return 'perplexity'; - if (process.env.GEMINI_API_KEY) return 'gemini'; - - return 'openai'; -} - -/** - * Get the singleton LLM provider. - */ -export function getLLM(): LLMProvider { - if (!_provider) { - _provider = createLLMProvider(resolveProviderType()); - } - return _provider; -} - -/** - * Create an LLM provider by type. - * For 'fallback', reads LLM_FALLBACK_ORDER env var (comma-separated provider names). - */ -export function createLLMProvider(type: LLMProviderType): LLMProvider { - switch (type) { - case 'azure': - return new AzureOpenAIProvider(); - case 'openai': - return new OpenAIProvider(); - case 'perplexity': - return new PerplexityProvider(); - case 'gemini': - return new GeminiProvider(); - case 'fallback': { - const order = (process.env.LLM_FALLBACK_ORDER ?? 'perplexity,openai,gemini') - .split(',') - .map(s => s.trim() as LLMProviderType) - .filter(name => name && name !== 'fallback'); // prevent infinite recursion - if (order.length === 0) { - throw new Error('LLM_FALLBACK_ORDER must contain at least one non-fallback provider'); - } - return new FallbackLLMProvider(order.map(createLLMProvider)); - } - case 'mock': - return new MockLLMProvider(); - default: - throw new Error( - `Unknown LLM_PROVIDER: '${type}'. Valid: azure, openai, perplexity, gemini, fallback, mock` - ); - } -} - -/** - * Set the singleton LLM provider (for testing). - */ -export function setLLM(provider: LLMProvider): void { - _provider = provider; -} - -/** - * @internal - */ -export function _resetLLM(): void { - _provider = null; -} diff --git a/vendor/bytelyst/llm/src/fallback.ts b/vendor/bytelyst/llm/src/fallback.ts deleted file mode 100644 index 1f4fcb3..0000000 --- a/vendor/bytelyst/llm/src/fallback.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Fallback chain utility. - * - * Wraps an ordered list of LLMProviders into a single LLMProvider that - * tries each in sequence, skipping unconfigured ones, and moves to the - * next on any error. Throws only when all providers are exhausted. - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from './types.js'; - -export function createFallbackChain(providers: LLMProvider[]): LLMProvider { - return { - isConfigured(): boolean { - return providers.some(p => p.isConfigured()); - }, - - async chatCompletion(req: ChatCompletionRequest): Promise { - const errors: string[] = []; - - for (const provider of providers) { - if (!provider.isConfigured()) continue; - try { - return await provider.chatCompletion(req); - } catch (err) { - errors.push(err instanceof Error ? err.message : String(err)); - } - } - - throw new Error( - errors.length > 0 - ? `All providers failed: ${errors.join(' | ')}` - : 'No providers configured' - ); - }, - }; -} diff --git a/vendor/bytelyst/llm/src/index.ts b/vendor/bytelyst/llm/src/index.ts deleted file mode 100644 index e3c2993..0000000 --- a/vendor/bytelyst/llm/src/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type { - LLMProvider, - ChatCompletionRequest, - ChatCompletionResponse, - ChatMessage, - TokenUsage, - LLMProviderType, - ContentPart, - TextContentPart, - ImageUrlContentPart, - EmbeddingRequest, - EmbeddingResponse, -} from './types.js'; - -export { isVisionMessage, hasVisionContent, buildVisionMessage, getMessageText } from './types.js'; - -export { getLLM, createLLMProvider, setLLM, _resetLLM } from './factory.js'; -export { createFallbackChain } from './fallback.js'; -export { AzureOpenAIProvider, type AzureOpenAIConfig } from './providers/azure-openai.js'; -export { GeminiProvider, type GeminiConfig } from './providers/gemini.js'; -export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js'; -export { PerplexityProvider, type PerplexityConfig } from './providers/perplexity.js'; -export { FallbackLLMProvider } from './providers/fallback.js'; -export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/providers/azure-openai.ts b/vendor/bytelyst/llm/src/providers/azure-openai.ts deleted file mode 100644 index badf172..0000000 --- a/vendor/bytelyst/llm/src/providers/azure-openai.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Azure OpenAI LLM provider. - * - * Uses Azure OpenAI REST API with api-key authentication. - * Reads config from AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_KEY, AZURE_OPENAI_DEPLOYMENT. - * Supports text, vision (multipart content), streaming, and embeddings. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; - -export interface AzureOpenAIConfig { - endpoint: string; - apiKey: string; - deployment: string; - embeddingDeployment?: string; - apiVersion?: string; -} - -export class AzureOpenAIProvider implements LLMProvider { - private config: AzureOpenAIConfig; - - constructor(config?: Partial) { - this.config = { - endpoint: config?.endpoint || process.env.AZURE_OPENAI_ENDPOINT || '', - apiKey: config?.apiKey || process.env.AZURE_OPENAI_KEY || process.env.OPENAI_API_KEY || '', - deployment: - config?.deployment || - process.env.AZURE_OPENAI_DEPLOYMENT || - process.env.OPENAI_MODEL || - 'gpt-4o-mini', - embeddingDeployment: - config?.embeddingDeployment || - process.env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT || - 'text-embedding-3-small', - apiVersion: config?.apiVersion || process.env.AZURE_OPENAI_API_VERSION || '2024-06-01', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.endpoint && this.config.apiKey && this.config.deployment); - } - - private getBaseUrl(): string { - return this.config.endpoint.replace(/\/+$/, ''); - } - - private getChatUrl(): string { - const base = this.getBaseUrl(); - const deployment = encodeURIComponent(this.config.deployment); - const version = encodeURIComponent(this.config.apiVersion!); - return `${base}/openai/deployments/${deployment}/chat/completions?api-version=${version}`; - } - - private getHeaders(): Record { - return { - 'Content-Type': 'application/json', - 'api-key': this.config.apiKey, - }; - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const url = this.getChatUrl(); - - const body = { - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const url = this.getChatUrl(); - - const body = { - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - stream: true, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI streaming error ${response.status}: ${text}`); - } - - if (!response.body) { - throw new Error('Azure OpenAI streaming: no response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); - if (data === '[DONE]') return; - try { - const parsed = JSON.parse(data) as { - choices: Array<{ delta: { content?: string } }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (delta) yield delta; - } catch { - // skip malformed SSE chunks - } - } - } - } finally { - reader.releaseLock(); - } - } - - async embed(req: EmbeddingRequest): Promise { - if (!this.isConfigured()) { - throw new Error( - 'Azure OpenAI is not configured (missing AZURE_OPENAI_ENDPOINT or AZURE_OPENAI_KEY)' - ); - } - - const base = this.getBaseUrl(); - const deployment = encodeURIComponent(this.config.embeddingDeployment!); - const version = encodeURIComponent(this.config.apiVersion!); - const url = `${base}/openai/deployments/${deployment}/embeddings?api-version=${version}`; - - const body = { - input: req.input, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Azure OpenAI embedding error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - data: Array<{ embedding: number[]; index: number }>; - model: string; - usage: { prompt_tokens: number; total_tokens: number }; - }; - - return { - embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), - model: data.model, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: 0, - totalTokens: data.usage.total_tokens, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/fallback.ts b/vendor/bytelyst/llm/src/providers/fallback.ts deleted file mode 100644 index 24e199c..0000000 --- a/vendor/bytelyst/llm/src/providers/fallback.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Fallback LLM provider. - * - * Tries each provider in order, falling back to the next on error or - * when a provider is not configured. Useful for resilient AI pipelines - * (e.g. perplexity → openai → gemini). - * - * Usage: - * const llm = new FallbackLLMProvider([ - * new PerplexityProvider(), - * new OpenAIProvider(), - * new GeminiProvider(), - * ]); - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; - -export class FallbackLLMProvider implements LLMProvider { - constructor(private readonly providers: LLMProvider[]) { - if (providers.length === 0) { - throw new Error('FallbackLLMProvider requires at least one provider'); - } - } - - isConfigured(): boolean { - return this.providers.some(p => p.isConfigured()); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - const errors: string[] = []; - - for (const provider of this.providers) { - if (!provider.isConfigured()) { - errors.push(`${provider.constructor.name}: not configured`); - continue; - } - try { - return await provider.chatCompletion(req); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`${provider.constructor.name}: ${msg}`); - } - } - - throw new Error(`All LLM providers failed:\n${errors.map(e => ` - ${e}`).join('\n')}`); - } -} diff --git a/vendor/bytelyst/llm/src/providers/gemini.ts b/vendor/bytelyst/llm/src/providers/gemini.ts deleted file mode 100644 index 194fbb7..0000000 --- a/vendor/bytelyst/llm/src/providers/gemini.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Google Gemini LLM provider. - * - * Uses Google's Generative Language API (not OpenAI-compatible). - * Reads config from GEMINI_API_KEY, GEMINI_MODEL. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - ChatMessage, - LLMProvider, -} from '../types.js'; -import { getMessageText } from '../types.js'; - -export interface GeminiConfig { - apiKey: string; - model?: string; -} - -interface GeminiPart { - text: string; -} - -interface GeminiContent { - role: 'user' | 'model'; - parts: GeminiPart[]; -} - -export class GeminiProvider implements LLMProvider { - private config: GeminiConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.GEMINI_API_KEY || '', - model: config?.model || process.env.GEMINI_MODEL || 'gemini-1.5-flash', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('Gemini is not configured (missing GEMINI_API_KEY)'); - } - - const model = req.model || this.config.model!; - const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${this.config.apiKey}`; - - const { systemInstruction, contents } = this.convertMessages(req.messages); - - const body: Record = { contents }; - if (systemInstruction) { - body.systemInstruction = { parts: [{ text: systemInstruction }] }; - } - if (req.temperature !== undefined) { - body.generationConfig = { temperature: req.temperature }; - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Gemini error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - candidates: Array<{ - content: { parts: GeminiPart[] }; - finishReason: string; - }>; - usageMetadata?: { - promptTokenCount: number; - candidatesTokenCount: number; - totalTokenCount: number; - }; - }; - - const content = data.candidates[0]?.content?.parts?.map(p => p.text).join('') ?? ''; - const finishReason = data.candidates[0]?.finishReason; - - return { - content, - model, - finishReason: - finishReason === 'STOP' ? 'stop' : finishReason === 'MAX_TOKENS' ? 'length' : null, - usage: { - promptTokens: data.usageMetadata?.promptTokenCount ?? 0, - completionTokens: data.usageMetadata?.candidatesTokenCount ?? 0, - totalTokens: data.usageMetadata?.totalTokenCount ?? 0, - }, - }; - } - - private convertMessages(messages: ChatMessage[]): { - systemInstruction: string | null; - contents: GeminiContent[]; - } { - const systemMessages = messages.filter(m => m.role === 'system'); - const systemInstruction = systemMessages.map(m => getMessageText(m)).join('\n') || null; - - const contents: GeminiContent[] = messages - .filter(m => m.role !== 'system') - .map(m => ({ - role: (m.role === 'assistant' ? 'model' : 'user') as 'user' | 'model', - parts: [{ text: getMessageText(m) }], - })); - - // Gemini requires at least one user turn - if (contents.length === 0) { - contents.push({ role: 'user', parts: [{ text: '' }] }); - } - - return { systemInstruction, contents }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/mock.ts b/vendor/bytelyst/llm/src/providers/mock.ts deleted file mode 100644 index ad624cf..0000000 --- a/vendor/bytelyst/llm/src/providers/mock.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Mock LLM provider — for testing. - * - * Returns pre-configured responses or a default echo response. - * Supports vision content, streaming, and embedding. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; -import { getMessageText } from '../types.js'; - -export class MockLLMProvider implements LLMProvider { - private responses: ChatCompletionResponse[] = []; - private embeddingResponses: EmbeddingResponse[] = []; - public calls: ChatCompletionRequest[] = []; - public embedCalls: EmbeddingRequest[] = []; - - constructor(responses?: ChatCompletionResponse[]) { - if (responses) this.responses = [...responses]; - } - - isConfigured(): boolean { - return true; - } - - /** Add a chat response to the queue. */ - addResponse(response: ChatCompletionResponse): void { - this.responses.push(response); - } - - /** Add an embedding response to the queue. */ - addEmbeddingResponse(response: EmbeddingResponse): void { - this.embeddingResponses.push(response); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - this.calls.push(req); - - if (this.responses.length > 0) { - return this.responses.shift()!; - } - - // Default echo response — handles both string and multipart content - const lastMessage = req.messages[req.messages.length - 1]; - const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; - return { - content: `Mock response to: ${text}`, - model: req.model ?? 'mock-model', - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - this.calls.push(req); - - if (this.responses.length > 0) { - const resp = this.responses.shift()!; - // Yield word-by-word to simulate streaming - const words = resp.content.split(' '); - for (const word of words) { - yield word + ' '; - } - return; - } - - const lastMessage = req.messages[req.messages.length - 1]; - const text = lastMessage ? getMessageText(lastMessage) : '(empty)'; - const words = `Mock response to: ${text}`.split(' '); - for (const word of words) { - yield word + ' '; - } - } - - async embed(req: EmbeddingRequest): Promise { - this.embedCalls.push(req); - - if (this.embeddingResponses.length > 0) { - return this.embeddingResponses.shift()!; - } - - // Default: return deterministic pseudo-embeddings (dimension 8 for testing) - const inputs = Array.isArray(req.input) ? req.input : [req.input]; - const embeddings = inputs.map(text => { - // Simple hash-based deterministic vector for testing - const vec = new Array(8).fill(0); - for (let i = 0; i < text.length; i++) { - vec[i % 8] += text.charCodeAt(i) / 1000; - } - // Normalize - const mag = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0)) || 1; - return vec.map(v => v / mag); - }); - - return { - embeddings, - model: req.model ?? 'mock-embedding-model', - usage: { - promptTokens: inputs.join(' ').split(/\s+/).length, - completionTokens: 0, - totalTokens: inputs.join(' ').split(/\s+/).length, - }, - }; - } - - /** Reset call history and responses. */ - reset(): void { - this.calls = []; - this.embedCalls = []; - this.responses = []; - this.embeddingResponses = []; - } -} diff --git a/vendor/bytelyst/llm/src/providers/openai.ts b/vendor/bytelyst/llm/src/providers/openai.ts deleted file mode 100644 index a355c14..0000000 --- a/vendor/bytelyst/llm/src/providers/openai.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * OpenAI direct LLM provider. - * - * Uses OpenAI REST API with Bearer token authentication. - * Reads config from OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL. - * Supports text, vision (multipart content), streaming, and embeddings. - */ - -import type { - ChatCompletionRequest, - ChatCompletionResponse, - EmbeddingRequest, - EmbeddingResponse, - LLMProvider, -} from '../types.js'; - -export interface OpenAIConfig { - apiKey: string; - baseUrl?: string; - model?: string; - embeddingModel?: string; -} - -export class OpenAIProvider implements LLMProvider { - private config: OpenAIConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.OPENAI_API_KEY || '', - baseUrl: config?.baseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', - model: config?.model || process.env.OPENAI_MODEL || 'gpt-4o-mini', - embeddingModel: - config?.embeddingModel || process.env.LLM_EMBEDDING_MODEL || 'text-embedding-3-small', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - private getBaseUrl(): string { - return this.config.baseUrl!.replace(/\/+$/, ''); - } - - private getHeaders(): Record { - return { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - }; - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/chat/completions`; - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - }, - }; - } - - async *chatCompletionStream(req: ChatCompletionRequest): AsyncIterable { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/chat/completions`; - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - stop: req.stop, - response_format: req.responseFormat, - stream: true, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI streaming error ${response.status}: ${text}`); - } - - if (!response.body) { - throw new Error('OpenAI streaming: no response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); - if (data === '[DONE]') return; - try { - const parsed = JSON.parse(data) as { - choices: Array<{ delta: { content?: string } }>; - }; - const delta = parsed.choices?.[0]?.delta?.content; - if (delta) yield delta; - } catch { - // skip malformed SSE chunks - } - } - } - } finally { - reader.releaseLock(); - } - } - - async embed(req: EmbeddingRequest): Promise { - if (!this.isConfigured()) { - throw new Error('OpenAI is not configured (missing OPENAI_API_KEY)'); - } - - const url = `${this.getBaseUrl()}/embeddings`; - - const body = { - model: req.model || this.config.embeddingModel, - input: req.input, - }; - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI embedding error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - data: Array<{ embedding: number[]; index: number }>; - model: string; - usage: { prompt_tokens: number; total_tokens: number }; - }; - - return { - embeddings: data.data.sort((a, b) => a.index - b.index).map(d => d.embedding), - model: data.model, - usage: { - promptTokens: data.usage.prompt_tokens, - completionTokens: 0, - totalTokens: data.usage.total_tokens, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/providers/perplexity.ts b/vendor/bytelyst/llm/src/providers/perplexity.ts deleted file mode 100644 index b778311..0000000 --- a/vendor/bytelyst/llm/src/providers/perplexity.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Perplexity LLM provider. - * - * Uses Perplexity's OpenAI-compatible API with real-time web search. - * Reads config from PERPLEXITY_API_KEY, PERPLEXITY_MODEL. - */ - -import type { ChatCompletionRequest, ChatCompletionResponse, LLMProvider } from '../types.js'; - -export interface PerplexityConfig { - apiKey: string; - model?: string; -} - -export class PerplexityProvider implements LLMProvider { - private config: PerplexityConfig; - - constructor(config?: Partial) { - this.config = { - apiKey: config?.apiKey || process.env.PERPLEXITY_API_KEY || '', - model: config?.model || process.env.PERPLEXITY_MODEL || 'sonar', - }; - } - - isConfigured(): boolean { - return Boolean(this.config.apiKey); - } - - async chatCompletion(req: ChatCompletionRequest): Promise { - if (!this.isConfigured()) { - throw new Error('Perplexity is not configured (missing PERPLEXITY_API_KEY)'); - } - - const body = { - model: req.model || this.config.model, - messages: req.messages, - temperature: req.temperature, - max_tokens: req.maxTokens, - top_p: req.topP, - }; - - const response = await fetch('https://api.perplexity.ai/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Perplexity error ${response.status}: ${text}`); - } - - const data = (await response.json()) as { - choices: Array<{ message: { content: string }; finish_reason: string }>; - model: string; - usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; - }; - - return { - content: data.choices[0]?.message?.content ?? '', - model: data.model, - finishReason: - (data.choices[0]?.finish_reason as ChatCompletionResponse['finishReason']) ?? null, - usage: { - promptTokens: data.usage?.prompt_tokens ?? 0, - completionTokens: data.usage?.completion_tokens ?? 0, - totalTokens: data.usage?.total_tokens ?? 0, - }, - }; - } -} diff --git a/vendor/bytelyst/llm/src/testing.ts b/vendor/bytelyst/llm/src/testing.ts deleted file mode 100644 index 18d2c6c..0000000 --- a/vendor/bytelyst/llm/src/testing.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test helpers for @bytelyst/llm. - */ - -import { setLLM, _resetLLM } from './factory.js'; -import { MockLLMProvider } from './providers/mock.js'; - -export function setTestLLMProvider(): MockLLMProvider { - const provider = new MockLLMProvider(); - setLLM(provider); - return provider; -} - -export function resetTestLLM(): void { - _resetLLM(); -} - -export { MockLLMProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/llm/src/types.ts b/vendor/bytelyst/llm/src/types.ts deleted file mode 100644 index c5eb284..0000000 --- a/vendor/bytelyst/llm/src/types.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Cloud-agnostic LLM provider interfaces. - * - * Provides a unified chat completion API that works with - * Azure OpenAI, OpenAI direct, Perplexity, Gemini, or mock providers. - * Supports text, vision (image), and embedding modalities. - */ - -// ── Content Parts (vision support) ──────────────────────────────── - -/** A text segment within a multipart message. */ -export interface TextContentPart { - type: 'text'; - text: string; -} - -/** An image URL segment within a multipart message (vision). */ -export interface ImageUrlContentPart { - type: 'image_url'; - image_url: { url: string; detail?: 'auto' | 'low' | 'high' }; -} - -/** A single part of a multipart message — text or image. */ -export type ContentPart = TextContentPart | ImageUrlContentPart; - -// ── Chat Messages ───────────────────────────────────────────────── - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - /** Text string OR multipart content array (for vision messages). */ - content: string | ContentPart[]; - name?: string; -} - -// ── Provider Interface ──────────────────────────────────────────── - -export interface LLMProvider { - /** Send a chat completion request. */ - chatCompletion(req: ChatCompletionRequest): Promise; - - /** Stream a chat completion response — yields content delta strings. */ - chatCompletionStream?(req: ChatCompletionRequest): AsyncIterable; - - /** Generate vector embeddings for input text(s). */ - embed?(req: EmbeddingRequest): Promise; - - /** Check if the provider is configured with valid credentials. */ - isConfigured(): boolean; -} - -// ── Chat Completion ─────────────────────────────────────────────── - -export interface ChatCompletionRequest { - messages: ChatMessage[]; - model?: string; - temperature?: number; - maxTokens?: number; - topP?: number; - stop?: string[]; - responseFormat?: { type: 'text' | 'json_object' }; -} - -export interface ChatCompletionResponse { - content: string; - model: string; - usage: TokenUsage; - finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null; -} - -// ── Embeddings ──────────────────────────────────────────────────── - -export interface EmbeddingRequest { - /** One or more texts to embed. */ - input: string | string[]; - /** Override the default embedding model. */ - model?: string; -} - -export interface EmbeddingResponse { - /** One embedding vector per input string. */ - embeddings: number[][]; - model: string; - usage: TokenUsage; -} - -// ── Shared ──────────────────────────────────────────────────────── - -export interface TokenUsage { - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -export type LLMProviderType = 'azure' | 'openai' | 'perplexity' | 'gemini' | 'fallback' | 'mock'; - -// ── Helpers ─────────────────────────────────────────────────────── - -/** Type guard: does this message contain image content parts? */ -export function isVisionMessage(msg: ChatMessage): boolean { - if (typeof msg.content === 'string') return false; - return msg.content.some(p => p.type === 'image_url'); -} - -/** Does the request contain any vision (image) messages? */ -export function hasVisionContent(req: ChatCompletionRequest): boolean { - return req.messages.some(isVisionMessage); -} - -/** Convenience builder for a user message with text + image. */ -export function buildVisionMessage( - text: string, - imageUrl: string, - detail: 'auto' | 'low' | 'high' = 'auto' -): ChatMessage { - return { - role: 'user', - content: [ - { type: 'text', text }, - { type: 'image_url', image_url: { url: imageUrl, detail } }, - ], - }; -} - -/** Extract plain text from a ChatMessage content (string or multipart). */ -export function getMessageText(msg: ChatMessage): string { - if (typeof msg.content === 'string') return msg.content; - return msg.content - .filter((p): p is TextContentPart => p.type === 'text') - .map(p => p.text) - .join('\n'); -} diff --git a/vendor/bytelyst/llm/tsconfig.json b/vendor/bytelyst/llm/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/llm/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/logger/package.json b/vendor/bytelyst/logger/package.json deleted file mode 100644 index 20446d0..0000000 --- a/vendor/bytelyst/logger/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/logger", - "version": "0.1.5", - "description": "Structured logger factory for Next.js dashboards and Node.js services", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/logger/src/__tests__/logger.test.ts b/vendor/bytelyst/logger/src/__tests__/logger.test.ts deleted file mode 100644 index 4d44a77..0000000 --- a/vendor/bytelyst/logger/src/__tests__/logger.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Tests for @bytelyst/logger package — createLogger + structured logging. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createLogger } from '../logger.js'; -import type { Logger } from '../types.js'; - -describe('createLogger', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let stdoutSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let stderrSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let consoleSpy: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let consoleErrorSpy: any; - - beforeEach(() => { - stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('returns a Logger with all four log methods', () => { - const log = createLogger({ service: 'test-service' }); - expect(typeof log.error).toBe('function'); - expect(typeof log.warn).toBe('function'); - expect(typeof log.info).toBe('function'); - expect(typeof log.debug).toBe('function'); - }); - - describe('production mode (isDev: false)', () => { - let log: Logger; - - beforeEach(() => { - log = createLogger({ service: 'platform-service', isDev: false }); - }); - - it('info writes structured JSON to stdout', () => { - log.info('Server started', { port: 4003 }); - - expect(stdoutSpy).toHaveBeenCalledTimes(1); - const output = stdoutSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('info'); - expect(parsed.message).toBe('Server started'); - expect(parsed.service).toBe('platform-service'); - expect(parsed.port).toBe(4003); - expect(parsed.timestamp).toBeDefined(); - }); - - it('error writes structured JSON to stderr', () => { - log.error('Connection failed', new Error('timeout')); - - expect(stderrSpy).toHaveBeenCalledTimes(1); - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('error'); - expect(parsed.message).toBe('Connection failed'); - expect(parsed.error).toBe('timeout'); - expect(parsed.stack).toBeDefined(); - expect(parsed.service).toBe('platform-service'); - }); - - it('warn writes structured JSON to stderr', () => { - log.warn('High memory usage', { usageMb: 512 }); - - expect(stderrSpy).toHaveBeenCalledTimes(1); - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - - expect(parsed.level).toBe('warn'); - expect(parsed.message).toBe('High memory usage'); - expect(parsed.usageMb).toBe(512); - }); - - it('debug does NOT emit in production mode', () => { - log.debug('Debug info'); - - expect(stdoutSpy).not.toHaveBeenCalled(); - expect(stderrSpy).not.toHaveBeenCalled(); - }); - - it('error handles non-Error objects', () => { - log.error('Strange error', 'just a string'); - - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.error).toBe('just a string'); - expect(parsed.stack).toBeUndefined(); - }); - - it('error handles undefined error', () => { - log.error('No error object'); - - const output = stderrSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.error).toBeUndefined(); - }); - - it('includes timestamp in ISO format', () => { - log.info('Timestamp test'); - - const output = stdoutSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(output.trim()); - expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - }); - - it('appends newline to output', () => { - log.info('Newline check'); - - const output = stdoutSpy.mock.calls[0][0] as string; - expect(output.endsWith('\n')).toBe(true); - }); - }); - - describe('dev mode (isDev: true)', () => { - let log: Logger; - - beforeEach(() => { - log = createLogger({ service: 'dev-service', isDev: true }); - }); - - it('info uses console.log with prefix', () => { - log.info('Dev message'); - - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('[INFO]'); - expect(output).toContain('Dev message'); - }); - - it('error uses console.error with prefix', () => { - log.error('Dev error', new Error('boom')); - - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - const output = consoleErrorSpy.mock.calls[0][0] as string; - expect(output).toContain('[ERROR]'); - expect(output).toContain('Dev error'); - expect(output).toContain('boom'); - }); - - it('warn uses console.error with prefix', () => { - log.warn('Dev warning'); - - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - const output = consoleErrorSpy.mock.calls[0][0] as string; - expect(output).toContain('[WARN]'); - expect(output).toContain('Dev warning'); - }); - - it('debug emits in dev mode', () => { - log.debug('Debug details', { key: 'value' }); - - expect(consoleSpy).toHaveBeenCalledTimes(1); - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('[DEBUG]'); - expect(output).toContain('Debug details'); - }); - - it('includes extra data in dev mode', () => { - log.info('With extras', { requestId: 'abc', userId: 'u1' }); - - const output = consoleSpy.mock.calls[0][0] as string; - expect(output).toContain('requestId'); - }); - }); - - describe('config defaults', () => { - it('defaults to dev mode when NODE_ENV is not production', () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - const log = createLogger({ service: 'test' }); - log.debug('Should emit in dev'); - - // Debug should emit in non-production - expect(consoleSpy.mock.calls.length + stdoutSpy.mock.calls.length).toBeGreaterThan(0); - - process.env.NODE_ENV = originalEnv; - }); - }); -}); diff --git a/vendor/bytelyst/logger/src/index.ts b/vendor/bytelyst/logger/src/index.ts deleted file mode 100644 index dcd5596..0000000 --- a/vendor/bytelyst/logger/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createLogger } from './logger.js'; -export type { Logger, LoggerConfig, LogLevel, LogPayload } from './types.js'; diff --git a/vendor/bytelyst/logger/src/logger.ts b/vendor/bytelyst/logger/src/logger.ts deleted file mode 100644 index 6e3d034..0000000 --- a/vendor/bytelyst/logger/src/logger.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { LogLevel, LogPayload, Logger, LoggerConfig } from './types.js'; - -function formatPayload( - service: string, - level: LogLevel, - message: string, - error?: unknown, - extra?: Record -): LogPayload { - const payload: LogPayload = { - level, - message, - timestamp: new Date().toISOString(), - service, - ...extra, - }; - - if (error instanceof Error) { - payload.error = error.message; - payload.stack = error.stack; - } else if (error !== undefined) { - payload.error = String(error); - } - - return payload; -} - -function emit(payload: LogPayload, isDev: boolean): void { - if (isDev) { - const { level, message, error: err, ...rest } = payload; - const prefix = `[${level.toUpperCase()}]`; - const extras = Object.keys(rest).length > 2 ? ` ${JSON.stringify(rest)}` : ''; - const errStr = err ? ` — ${err}` : ''; - - if (level === 'error' || level === 'warn') { - // eslint-disable-next-line no-console - console.error(`${prefix} ${message}${errStr}${extras}`); - } else { - // eslint-disable-next-line no-console - console.log(`${prefix} ${message}${extras}`); - } - } else { - const out = JSON.stringify(payload); - if (payload.level === 'error' || payload.level === 'warn') { - process.stderr.write(out + '\n'); - } else { - process.stdout.write(out + '\n'); - } - } -} - -/** - * Create a structured logger for a service or dashboard. - * - * @example - * ```ts - * import { createLogger } from "@bytelyst/logger"; - * const log = createLogger({ service: "admin-dashboard" }); - * log.error("Login failed", error, { userId }); - * log.info("Seed complete", { containers: 8 }); - * ``` - */ -export function createLogger(config: LoggerConfig): Logger { - const { service } = config; - const isDev = config.isDev ?? process.env.NODE_ENV !== 'production'; - - return { - error(message: string, error?: unknown, extra?: Record): void { - emit(formatPayload(service, 'error', message, error, extra), isDev); - }, - warn(message: string, extra?: Record): void { - emit(formatPayload(service, 'warn', message, undefined, extra), isDev); - }, - info(message: string, extra?: Record): void { - emit(formatPayload(service, 'info', message, undefined, extra), isDev); - }, - debug(message: string, extra?: Record): void { - if (isDev) { - emit(formatPayload(service, 'debug', message, undefined, extra), isDev); - } - }, - }; -} diff --git a/vendor/bytelyst/logger/src/types.ts b/vendor/bytelyst/logger/src/types.ts deleted file mode 100644 index eb32ef9..0000000 --- a/vendor/bytelyst/logger/src/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type LogLevel = 'info' | 'warn' | 'error' | 'debug'; - -export interface LogPayload { - level: LogLevel; - message: string; - timestamp: string; - service: string; - error?: string; - stack?: string; - [key: string]: unknown; -} - -export interface LoggerConfig { - /** Service name included in every log entry (e.g. "admin-dashboard", "billing-service") */ - service: string; - /** Override NODE_ENV detection for dev/prod mode. Default: reads process.env.NODE_ENV */ - isDev?: boolean; -} - -export interface Logger { - error(message: string, error?: unknown, extra?: Record): void; - warn(message: string, extra?: Record): void; - info(message: string, extra?: Record): void; - debug(message: string, extra?: Record): void; -} diff --git a/vendor/bytelyst/logger/tsconfig.json b/vendor/bytelyst/logger/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/logger/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/marketplace-client/package.json b/vendor/bytelyst/marketplace-client/package.json deleted file mode 100644 index 4036336..0000000 --- a/vendor/bytelyst/marketplace-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/marketplace-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/marketplace-client/src/client.test.ts b/vendor/bytelyst/marketplace-client/src/client.test.ts deleted file mode 100644 index b1b80ef..0000000 --- a/vendor/bytelyst/marketplace-client/src/client.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createMarketplaceClient } from './client.js'; -import type { - MarketplaceListingDoc, - MarketplaceReviewDoc, - MarketplaceInstallDoc, -} from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', -}; - -function mockListing(overrides?: Partial): MarketplaceListingDoc { - return { - id: 'lst_1', - productId: 'testapp', - templateType: 'fasting_protocol', - authorId: 'user-1', - authorName: 'Alice', - title: '16:8 Protocol', - shortDescription: 'Classic intermittent fasting', - description: 'A detailed 16:8 fasting protocol.', - tags: ['fasting', 'beginner'], - category: 'protocols', - payload: { hours: 16, breakHours: 8 }, - pricingModel: 'free', - priceInCents: 0, - certificationStatus: 'approved', - installCount: 100, - reviewCount: 10, - averageRating: 4.5, - visibility: 'public', - featured: false, - version: '1.0.0', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockReview(overrides?: Partial): MarketplaceReviewDoc { - return { - id: 'rev_1', - listingId: 'lst_1', - productId: 'testapp', - authorId: 'user-2', - rating: 5, - title: 'Great protocol!', - body: 'Really works well for me.', - verified: true, - createdAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockInstall(overrides?: Partial): MarketplaceInstallDoc { - return { - id: 'inst_1', - listingId: 'lst_1', - productId: 'testapp', - userId: 'user-2', - version: '1.0.0', - installedAt: '2026-01-01T00:00:00Z', - uninstalledAt: null, - ...overrides, - }; -} - -describe('createMarketplaceClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should list listings', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ listings: [mockListing()], total: 1 }), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listListings({ category: 'protocols' }); - expect(result.listings).toHaveLength(1); - expect(result.total).toBe(1); - - const fetchMock = globalThis.fetch as ReturnType; - const url = fetchMock.mock.calls[0][0] as string; - expect(url).toContain('category=protocols'); - }); - - it('should get a listing by id', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.getListing('lst_1'); - expect(result.title).toBe('16:8 Protocol'); - }); - - it('should create a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.createListing({ - templateType: 'fasting_protocol', - title: '16:8 Protocol', - shortDescription: 'Classic', - description: 'Detailed', - category: 'protocols', - payload: { hours: 16 }, - }); - expect(result.id).toBe('lst_1'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/marketplace/listings'), - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should submit for certification', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockListing({ certificationStatus: 'submitted' })), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.submitForCertification('lst_1', 'Ready for review'); - expect(result.certificationStatus).toBe('submitted'); - }); - - it('should install a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockInstall()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.installListing('lst_1'); - expect(result.listingId).toBe('lst_1'); - }); - - it('should list my installs', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockInstall()]), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listMyInstalls(); - expect(result).toHaveLength(1); - }); - - it('should list reviews', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockReview()]), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.listReviews('lst_1'); - expect(result).toHaveLength(1); - expect(result[0].rating).toBe(5); - }); - - it('should create a review', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockReview()), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.createReview('lst_1', { - rating: 5, - title: 'Great!', - body: 'Love it.', - }); - expect(result.rating).toBe(5); - }); - - it('should update a listing', async () => { - const updated = mockListing({ title: 'Updated Title' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(updated), - }) - ); - const client = createMarketplaceClient(baseConfig); - const result = await client.updateListing('lst_1', { title: 'Updated Title' }); - expect(result.title).toBe('Updated Title'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/marketplace/listings/lst_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should uninstall a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect(client.uninstallListing('lst_1')).resolves.toBeUndefined(); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/marketplace/listings/lst_1/uninstall', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should report a listing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect( - client.reportListing('lst_1', { reason: 'spam', details: 'Looks like spam' }) - ).resolves.toBeUndefined(); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - const client = createMarketplaceClient(baseConfig); - await expect(client.getListing('lst_1')).rejects.toThrow('getListing failed: 500'); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ listings: [], total: 0 }), - }) - ); - const client = createMarketplaceClient(baseConfig); - await client.listListings(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/marketplace-client/src/client.ts b/vendor/bytelyst/marketplace-client/src/client.ts deleted file mode 100644 index bf3a63e..0000000 --- a/vendor/bytelyst/marketplace-client/src/client.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Browser/React Native-safe marketplace client for platform-service. - * - * Wraps platform-service /marketplace/* endpoints. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - CreateListingInput, - MarketplaceClient, - MarketplaceClientConfig, - MarketplaceInstallDoc, - MarketplaceListingDoc, - MarketplaceReviewDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createMarketplaceClient(config: MarketplaceClientConfig): MarketplaceClient { - const { baseUrl, productId, getAccessToken } = config; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - // ── Listings ────────────────────────────────────── - - async function listListings(query?: { - templateType?: string; - category?: string; - tags?: string; - pricingModel?: string; - sortBy?: string; - q?: string; - limit?: number; - offset?: number; - }): Promise<{ listings: MarketplaceListingDoc[]; total: number }> { - const params = new URLSearchParams(); - if (query?.templateType) params.set('templateType', query.templateType); - if (query?.category) params.set('category', query.category); - if (query?.tags) params.set('tags', query.tags); - if (query?.pricingModel) params.set('pricingModel', query.pricingModel); - if (query?.sortBy) params.set('sortBy', query.sortBy); - if (query?.q) params.set('q', query.q); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.offset) params.set('offset', String(query.offset)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/marketplace/listings?${qs}` : `${baseUrl}/marketplace/listings`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listListings failed: ${res.status}`); - return (await res.json()) as { listings: MarketplaceListingDoc[]; total: number }; - } - - async function getListing(id: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, - { headers: headers() } - ); - if (!res.ok) throw new Error(`getListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function createListing(input: CreateListingInput): Promise { - const res = await globalThis.fetch(`${baseUrl}/marketplace/listings`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`createListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function updateListing( - id: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateListing failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - async function submitForCertification( - id: string, - notes?: string - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(id)}/submit`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify({ notes }), - } - ); - if (!res.ok) throw new Error(`submitForCertification failed: ${res.status}`); - return (await res.json()) as MarketplaceListingDoc; - } - - // ── Installs ────────────────────────────────────── - - async function installListing(listingId: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/install`, - { - method: 'POST', - headers: headers(), - } - ); - if (!res.ok) throw new Error(`installListing failed: ${res.status}`); - return (await res.json()) as MarketplaceInstallDoc; - } - - async function uninstallListing(listingId: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/uninstall`, - { - method: 'POST', - headers: headers(), - } - ); - if (!res.ok) throw new Error(`uninstallListing failed: ${res.status}`); - } - - async function listMyInstalls(query?: { - limit?: number; - offset?: number; - }): Promise { - const params = new URLSearchParams(); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.offset) params.set('offset', String(query.offset)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/marketplace/installs?${qs}` : `${baseUrl}/marketplace/installs`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listMyInstalls failed: ${res.status}`); - return (await res.json()) as MarketplaceInstallDoc[]; - } - - // ── Reviews ─────────────────────────────────────── - - async function listReviews( - listingId: string, - query?: { sortBy?: string; limit?: number } - ): Promise { - const params = new URLSearchParams(); - if (query?.sortBy) params.set('sortBy', query.sortBy); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs - ? `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews?${qs}` - : `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listReviews failed: ${res.status}`); - return (await res.json()) as MarketplaceReviewDoc[]; - } - - async function createReview( - listingId: string, - input: { rating: number; title: string; body: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/reviews`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - } - ); - if (!res.ok) throw new Error(`createReview failed: ${res.status}`); - return (await res.json()) as MarketplaceReviewDoc; - } - - // ── Reports ─────────────────────────────────────── - - async function reportListing( - listingId: string, - input: { reason: string; details: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/marketplace/listings/${encodeURIComponent(listingId)}/report`, - { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - } - ); - if (!res.ok) throw new Error(`reportListing failed: ${res.status}`); - } - - return { - listListings, - getListing, - createListing, - updateListing, - submitForCertification, - installListing, - uninstallListing, - listMyInstalls, - listReviews, - createReview, - reportListing, - }; -} diff --git a/vendor/bytelyst/marketplace-client/src/index.ts b/vendor/bytelyst/marketplace-client/src/index.ts deleted file mode 100644 index 10febd0..0000000 --- a/vendor/bytelyst/marketplace-client/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface MarketplaceClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface MarketplaceListing { - id: string; - title: string; - description: string; - category: string; - author: string; - downloads: number; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: MarketplaceClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - "Content-Type": "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createMarketplaceClient(opts: MarketplaceClientOptions) { - const { baseUrl } = opts; - - return { - async listListings(listOpts?: { - category?: string; - }): Promise { - const q = new URLSearchParams(); - if (listOpts?.category !== undefined && listOpts.category !== "") { - q.set("category", listOpts.category); - } - const query = q.toString(); - const path = - query.length > 0 - ? `/marketplace/listings?${query}` - : "/marketplace/listings"; - const res = await fetch(joinUrl(baseUrl, path), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - async installListing( - listingId: string - ): Promise<{ success: boolean }> { - const res = await fetch( - joinUrl( - baseUrl, - `/marketplace/listings/${encodeURIComponent(listingId)}/install` - ), - { method: "POST", headers: headers(opts), body: "{}" } - ); - return parseJson<{ success: boolean }>(res); - }, - }; -} diff --git a/vendor/bytelyst/marketplace-client/src/types.ts b/vendor/bytelyst/marketplace-client/src/types.ts deleted file mode 100644 index 482950d..0000000 --- a/vendor/bytelyst/marketplace-client/src/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Types for @bytelyst/marketplace-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface MarketplaceListingDoc { - id: string; - productId: string; - templateType: string; - authorId: string; - authorName: string; - title: string; - shortDescription: string; - description: string; - tags: string[]; - category: string; - payload: Record; - pricingModel: 'free' | 'paid' | 'freemium'; - priceInCents: number; - certificationStatus: 'draft' | 'submitted' | 'in_review' | 'approved' | 'rejected' | 'suspended'; - installCount: number; - reviewCount: number; - averageRating: number; - visibility: 'private' | 'unlisted' | 'public'; - featured: boolean; - version: string; - createdAt: string; - updatedAt: string; -} - -export interface MarketplaceReviewDoc { - id: string; - listingId: string; - productId: string; - authorId: string; - rating: number; - title: string; - body: string; - verified: boolean; - createdAt: string; -} - -export interface MarketplaceInstallDoc { - id: string; - listingId: string; - productId: string; - userId: string; - version: string; - installedAt: string; - uninstalledAt: string | null; -} - -export interface CreateListingInput { - templateType: string; - title: string; - shortDescription: string; - description: string; - tags?: string[]; - category: string; - payload: Record; - pricingModel?: 'free' | 'paid' | 'freemium'; - priceInCents?: number; - visibility?: 'private' | 'unlisted' | 'public'; - version?: string; -} - -export interface MarketplaceClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; -} - -export interface MarketplaceClient { - // Listings - listListings(query?: { - templateType?: string; - category?: string; - tags?: string; - pricingModel?: string; - sortBy?: string; - q?: string; - limit?: number; - offset?: number; - }): Promise<{ listings: MarketplaceListingDoc[]; total: number }>; - getListing(id: string): Promise; - createListing(input: CreateListingInput): Promise; - updateListing( - id: string, - updates: Partial - ): Promise; - submitForCertification(id: string, notes?: string): Promise; - - // Installs - installListing(listingId: string): Promise; - uninstallListing(listingId: string): Promise; - listMyInstalls(query?: { limit?: number; offset?: number }): Promise; - - // Reviews - listReviews( - listingId: string, - query?: { sortBy?: string; limit?: number } - ): Promise; - createReview( - listingId: string, - input: { rating: number; title: string; body: string } - ): Promise; - - // Reports - reportListing(listingId: string, input: { reason: string; details: string }): Promise; -} diff --git a/vendor/bytelyst/marketplace-client/tsconfig.json b/vendor/bytelyst/marketplace-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/marketplace-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/monitoring/package.json b/vendor/bytelyst/monitoring/package.json deleted file mode 100644 index 1d9b877..0000000 --- a/vendor/bytelyst/monitoring/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/monitoring", - "version": "0.1.5", - "type": "module", - "description": "Health-check aggregation utilities for ByteLyst services and dashboards", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts b/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts deleted file mode 100644 index b48f4dd..0000000 --- a/vendor/bytelyst/monitoring/src/__tests__/monitoring.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { checkService, generateHealthReport, type ServiceTarget } from '../index.js'; - -type FetchInput = Parameters[0]; -type MockFetchResult = Pick; - -function mockFetch(handler: (input: FetchInput) => Promise): void { - globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; -} - -describe('monitoring', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('checkService returns healthy for 200', async () => { - mockFetch(async () => ({ - ok: true, - status: 200, - json: async () => ({ status: 'ok' }), - })); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('healthy'); - expect(res.details).toEqual({ status: 'ok' }); - }); - - it('checkService returns unhealthy for non-2xx', async () => { - mockFetch(async () => ({ - ok: false, - status: 503, - json: async () => ({}), - })); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('unhealthy'); - expect(res.error).toContain('HTTP 503'); - }); - - it('checkService returns unreachable on fetch error', async () => { - mockFetch(async () => { - throw new Error('network'); - }); - - const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' }); - expect(res.status).toBe('unreachable'); - expect(res.error).toContain('network'); - }); - - it('generateHealthReport sets overall=down when all unreachable', async () => { - mockFetch(async () => { - throw new Error('network'); - }); - - const services: ServiceTarget[] = [ - { name: 'a', url: 'http://a', path: '/health' }, - { name: 'b', url: 'http://b', path: '/health' }, - ]; - - const report = await generateHealthReport(services, { timeoutMs: 10 }); - expect(report.overall).toBe('down'); - expect(report.summary.unreachable).toBe(2); - }); - - it('generateHealthReport sets overall=degraded when mixed', async () => { - mockFetch(async url => { - if (String(url).includes('good')) return { ok: true, status: 200, json: async () => ({}) }; - return { ok: false, status: 500, json: async () => ({}) }; - }); - - const services: ServiceTarget[] = [ - { name: 'good', url: 'http://good', path: '/health' }, - { name: 'bad', url: 'http://bad', path: '/health' }, - ]; - - const report = await generateHealthReport(services); - expect(report.overall).toBe('degraded'); - expect(report.summary.healthy).toBe(1); - expect(report.summary.unhealthy).toBe(1); - }); - - it('generateHealthReport sets overall=healthy when all healthy', async () => { - mockFetch(async () => ({ - ok: true, - status: 200, - json: async () => ({}), - })); - - const services: ServiceTarget[] = [ - { name: 'a', url: 'http://a', path: '/health' }, - { name: 'b', url: 'http://b', path: '/health' }, - ]; - - const report = await generateHealthReport(services); - expect(report.overall).toBe('healthy'); - expect(report.summary.healthy).toBe(2); - }); -}); diff --git a/vendor/bytelyst/monitoring/src/health.ts b/vendor/bytelyst/monitoring/src/health.ts deleted file mode 100644 index 6e99207..0000000 --- a/vendor/bytelyst/monitoring/src/health.ts +++ /dev/null @@ -1,105 +0,0 @@ -export interface ServiceTarget { - name: string; - url: string; - path: string; -} - -export interface ServiceCheck { - name: string; - url: string; - status: 'healthy' | 'unhealthy' | 'unreachable'; - responseTimeMs: number; - details?: Record; - error?: string; -} - -export interface HealthReport { - overall: 'healthy' | 'degraded' | 'down'; - timestamp: string; - services: ServiceCheck[]; - summary: { healthy: number; unhealthy: number; unreachable: number; total: number }; -} - -/** - * Default service targets (LysnrAI local stack). - */ -export const DEFAULT_SERVICES: ServiceTarget[] = [ - { name: 'Backend API', url: process.env.BACKEND_URL || 'http://localhost:8000', path: '/health' }, - { - name: 'Platform Service', - url: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003', - path: '/health', - }, - { - name: 'Admin Dashboard', - url: process.env.ADMIN_DASHBOARD_URL || 'http://localhost:3001', - path: '/api/health', - }, - { - name: 'User Dashboard', - url: process.env.USER_DASHBOARD_URL || 'http://localhost:3002', - path: '/api/health', - }, -]; - -export async function checkService( - svc: ServiceTarget, - opts?: { timeoutMs?: number } -): Promise { - const fullUrl = `${svc.url}${svc.path}`; - const start = performance.now(); - - try { - const res = await fetch(fullUrl, { signal: AbortSignal.timeout(opts?.timeoutMs ?? 5_000) }); - const elapsed = Math.round(performance.now() - start); - - if (res.ok) { - let details: Record | undefined; - try { - details = (await res.json()) as Record; - } catch { - /* ignore */ - } - return { name: svc.name, url: svc.url, status: 'healthy', responseTimeMs: elapsed, details }; - } - - return { - name: svc.name, - url: svc.url, - status: 'unhealthy', - responseTimeMs: elapsed, - error: `HTTP ${res.status}`, - }; - } catch (err) { - const elapsed = Math.round(performance.now() - start); - return { - name: svc.name, - url: svc.url, - status: 'unreachable', - responseTimeMs: elapsed, - error: String(err), - }; - } -} - -export async function generateHealthReport( - services: ServiceTarget[] = DEFAULT_SERVICES, - opts?: { timeoutMs?: number } -): Promise { - const checks = await Promise.all(services.map(svc => checkService(svc, opts))); - - const healthy = checks.filter(c => c.status === 'healthy').length; - const unhealthy = checks.filter(c => c.status === 'unhealthy').length; - const unreachable = checks.filter(c => c.status === 'unreachable').length; - - let overall: HealthReport['overall'] = 'healthy'; - if (unreachable === checks.length) overall = 'down'; - else if (unhealthy > 0 || unreachable > 0) overall = 'degraded'; - - return { - overall, - timestamp: new Date().toISOString(), - services: checks, - summary: { healthy, unhealthy, unreachable, total: checks.length }, - }; -} diff --git a/vendor/bytelyst/monitoring/src/index.ts b/vendor/bytelyst/monitoring/src/index.ts deleted file mode 100644 index 9d719a5..0000000 --- a/vendor/bytelyst/monitoring/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './health.js'; diff --git a/vendor/bytelyst/monitoring/tsconfig.json b/vendor/bytelyst/monitoring/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/monitoring/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/offline-queue/package.json b/vendor/bytelyst/offline-queue/package.json deleted file mode 100644 index 4aadb6a..0000000 --- a/vendor/bytelyst/offline-queue/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/offline-queue", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe persistent offline retry queue", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/offline-queue/src/index.test.ts b/vendor/bytelyst/offline-queue/src/index.test.ts deleted file mode 100644 index 2135622..0000000 --- a/vendor/bytelyst/offline-queue/src/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createOfflineQueue } from './index.js'; - -function createMemoryStorage() { - const store: Record = {}; - return { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - _store: store, - }; -} - -describe('createOfflineQueue', () => { - it('should start with zero length', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - expect(queue.length()).toBe(0); - }); - - it('should enqueue items', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } }); - expect(queue.length()).toBe(1); - - queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } }); - expect(queue.length()).toBe(2); - }); - - it('should replace existing entry with same id + action', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } }); - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } }); - expect(queue.length()).toBe(1); - }); - - it('should not replace entries with different action', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} }); - queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} }); - expect(queue.length()).toBe(2); - }); - - it('should flush all items successfully', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } }); - queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } }); - - const executor = vi.fn().mockResolvedValue(undefined); - const result = await queue.flush(executor); - - expect(result.flushed).toBe(2); - expect(result.failed).toBe(0); - expect(queue.length()).toBe(0); - expect(executor).toHaveBeenCalledTimes(2); - }); - - it('should retry failed items on next flush', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - - const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); - const result = await queue.flush(failingExecutor); - - expect(result.flushed).toBe(0); - expect(result.failed).toBe(1); - expect(queue.length()).toBe(1); - }); - - it('should drop items after max retries', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - - const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); - - // Retry 1 - await queue.flush(failingExecutor); - expect(queue.length()).toBe(1); - - // Retry 2 — should be dropped - await queue.flush(failingExecutor); - expect(queue.length()).toBe(0); - }); - - it('should cap queue at maxQueueSize', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 }); - - for (let i = 0; i < 5; i++) { - queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} }); - } - - expect(queue.length()).toBe(3); - }); - - it('should persist to storage', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } }); - - const stored = JSON.parse(storage._store['test-q']); - expect(stored).toHaveLength(1); - expect(stored[0].id).toBe('1'); - }); - - it('should clear the queue', () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); - queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} }); - expect(queue.length()).toBe(2); - - queue.clear(); - expect(queue.length()).toBe(0); - }); - - it('should return empty result when flushing empty queue', async () => { - const storage = createMemoryStorage(); - const queue = createOfflineQueue({ storageKey: 'test-q', storage }); - - const executor = vi.fn(); - const result = await queue.flush(executor); - - expect(result.flushed).toBe(0); - expect(result.failed).toBe(0); - expect(executor).not.toHaveBeenCalled(); - }); -}); diff --git a/vendor/bytelyst/offline-queue/src/index.ts b/vendor/bytelyst/offline-queue/src/index.ts deleted file mode 100644 index d11a1d0..0000000 --- a/vendor/bytelyst/offline-queue/src/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Persistent offline retry queue for browser and React Native. - * - * When an API call fails (offline, timeout, etc.), the operation is - * queued in configurable storage and retried on the next flush. - * - * No Node.js, React, or React Native dependencies. - * - * @example - * ```ts - * import { createOfflineQueue } from '@bytelyst/offline-queue'; - * - * const queue = createOfflineQueue({ - * storageKey: 'nomgap-offline-queue', - * storage: mmkvStorage, // or localStorage - * }); - * - * // On API failure: - * queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } }); - * - * // On app foreground / auth success: - * const result = await queue.flush(async (action, path, payload) => { - * await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload); - * }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface QueueStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface OfflineQueueConfig { - /** Storage key for persisting the queue. */ - storageKey: string; - - /** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */ - storage: QueueStorage; - - /** Maximum retry attempts per item. Default: 5. */ - maxRetries?: number; - - /** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */ - maxQueueSize?: number; -} - -export interface QueueItem { - id: string; - action: string; - path: string; - payload: Record; - enqueuedAt: number; - retryCount: number; -} - -export interface FlushResult { - flushed: number; - failed: number; -} - -export interface OfflineQueue { - /** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */ - enqueue(item: { - id: string; - action: string; - path: string; - payload: Record; - }): void; - - /** Flush the queue — retry all pending items via the provided executor. */ - flush( - executor: (action: string, path: string, payload: Record) => Promise - ): Promise; - - /** Get current queue length. */ - length(): number; - - /** Clear the entire queue. */ - clear(): void; -} - -// ── Factory ────────────────────────────────────────────────── - -export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue { - const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config; - - function loadQueue(): QueueItem[] { - try { - const raw = storage.getItem(storageKey); - if (!raw) return []; - return JSON.parse(raw) as QueueItem[]; - } catch { - return []; - } - } - - function saveQueue(queue: QueueItem[]): void { - try { - storage.setItem(storageKey, JSON.stringify(queue)); - } catch { - // Storage unavailable - } - } - - function enqueue(item: { - id: string; - action: string; - path: string; - payload: Record; - }): void { - const queue = loadQueue(); - - // Replace existing entry for same entity + action - const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action)); - - // Cap queue size - if (filtered.length >= maxQueueSize) { - filtered.shift(); - } - - filtered.push({ - ...item, - enqueuedAt: Date.now(), - retryCount: 0, - }); - - saveQueue(filtered); - } - - async function flush( - executor: (action: string, path: string, payload: Record) => Promise - ): Promise { - const queue = loadQueue(); - if (queue.length === 0) return { flushed: 0, failed: 0 }; - - let flushed = 0; - const remaining: QueueItem[] = []; - - for (const item of queue) { - try { - await executor(item.action, item.path, item.payload); - flushed++; - } catch { - if (item.retryCount + 1 < maxRetries) { - remaining.push({ ...item, retryCount: item.retryCount + 1 }); - } - // else: silently drop — too many retries - } - } - - saveQueue(remaining); - return { flushed, failed: remaining.length }; - } - - function length(): number { - return loadQueue().length; - } - - function clear(): void { - saveQueue([]); - } - - return { enqueue, flush, length, clear }; -} diff --git a/vendor/bytelyst/offline-queue/tsconfig.json b/vendor/bytelyst/offline-queue/tsconfig.json deleted file mode 100644 index 5a24989..0000000 --- a/vendor/bytelyst/offline-queue/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/ollama-client/package.json b/vendor/bytelyst/ollama-client/package.json deleted file mode 100644 index a449ce0..0000000 --- a/vendor/bytelyst/ollama-client/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@bytelyst/ollama-client", - "version": "0.1.6", - "description": "Shared Ollama API client — streaming chat, embeddings, model management, health checks", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "vitest": "^3.0.0", - "typescript": "^5.7.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.test.ts b/vendor/bytelyst/ollama-client/src/client-parsers.test.ts deleted file mode 100644 index bd65194..0000000 --- a/vendor/bytelyst/ollama-client/src/client-parsers.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; - -function createResponse(body: string): Response { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(body)); - controller.close(); - }, - }); - return { ok: true, body: stream } as unknown as Response; -} - -describe('consumeSSEStream', () => { - it('parses data: lines and calls onData', async () => { - const response = createResponse('data: {"msg":"hello"}\ndata: {"msg":"world"}\n\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - - expect(onData).toHaveBeenCalledTimes(2); - expect(onData).toHaveBeenCalledWith({ msg: 'hello' }); - expect(onData).toHaveBeenCalledWith({ msg: 'world' }); - expect(onDone).toHaveBeenCalled(); - }); - - it('stops on [DONE] marker', async () => { - const response = createResponse('data: {"msg":"hello"}\ndata: [DONE]\ndata: {"msg":"after"}\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - - expect(onData).toHaveBeenCalledTimes(1); - expect(onDone).toHaveBeenCalled(); - }); - - it('calls onDone when response has no body', async () => { - const response = { ok: true, body: null } as unknown as Response; - const onDone = vi.fn(); - - await consumeSSEStream(response, vi.fn(), onDone); - expect(onDone).toHaveBeenCalled(); - }); - - it('skips malformed SSE data', async () => { - const response = createResponse('data: not-json\ndata: {"valid":true}\n'); - const onData = vi.fn(); - const onDone = vi.fn(); - - await consumeSSEStream(response, onData, onDone); - expect(onData).toHaveBeenCalledTimes(1); - expect(onData).toHaveBeenCalledWith({ valid: true }); - }); -}); - -describe('consumeNdjsonStream', () => { - it('parses NDJSON lines and calls onChunk', async () => { - const response = createResponse('{"a":1}\n{"a":2}\n'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - - expect(onChunk).toHaveBeenCalledTimes(2); - expect(onChunk).toHaveBeenCalledWith({ a: 1 }); - expect(onChunk).toHaveBeenCalledWith({ a: 2 }); - }); - - it('handles no body', async () => { - const response = { ok: true, body: null } as unknown as Response; - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).not.toHaveBeenCalled(); - }); - - it('processes remaining buffer', async () => { - const response = createResponse('{"a":1}\n{"a":2}'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).toHaveBeenCalledTimes(2); - }); - - it('skips malformed lines', async () => { - const response = createResponse('{"a":1}\nbad\n{"a":2}\n'); - const onChunk = vi.fn(); - - await consumeNdjsonStream(response, onChunk); - expect(onChunk).toHaveBeenCalledTimes(2); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/client-parsers.ts b/vendor/bytelyst/ollama-client/src/client-parsers.ts deleted file mode 100644 index a86c356..0000000 --- a/vendor/bytelyst/ollama-client/src/client-parsers.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Browser-side stream consumers for Next.js API route clients. - * - * These functions consume a `Response` from a fetch call that returns - * streaming data (SSE or NDJSON) and invoke callbacks for each chunk. - */ - -/** - * Consume a Server-Sent Events (SSE) stream from a Response. - * - * Parses `data:` lines and invokes `onData` for each parsed JSON object. - * Stops when the stream ends or `[DONE]` is received. - * - * @param response - Fetch Response with SSE body - * @param onData - Callback for each parsed data event - * @param onDone - Callback when stream completes - */ -export async function consumeSSEStream( - response: Response, - onData: (data: Record) => void, - onDone: () => void -): Promise { - if (!response.body) { - onDone(); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - if (trimmed.startsWith('data:')) { - const payload = trimmed.slice(5).trim(); - if (payload === '[DONE]') { - onDone(); - return; - } - try { - onData(JSON.parse(payload) as Record); - } catch { - // skip malformed SSE data - } - } - } - } - } finally { - reader.releaseLock(); - } - - onDone(); -} - -/** - * Consume an NDJSON (newline-delimited JSON) stream from a Response. - * - * Parses each line as JSON and invokes `onChunk` for each parsed object. - * - * @param response - Fetch Response with NDJSON body - * @param onChunk - Callback for each parsed NDJSON line - */ -export async function consumeNdjsonStream( - response: Response, - onChunk: (chunk: T) => void -): Promise { - if (!response.body) return; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - onChunk(JSON.parse(trimmed) as T); - } catch { - // skip malformed lines - } - } - } - - // Process remaining buffer - if (buffer.trim()) { - try { - onChunk(JSON.parse(buffer.trim()) as T); - } catch { - // skip - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/vendor/bytelyst/ollama-client/src/client.test.ts b/vendor/bytelyst/ollama-client/src/client.test.ts deleted file mode 100644 index accd16c..0000000 --- a/vendor/bytelyst/ollama-client/src/client.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { OllamaClient } from './client.js'; - -const BASE_URL = 'http://localhost:11434'; - -function mockFetch(response: unknown, options?: { ok?: boolean; status?: number }) { - return vi.fn().mockResolvedValue({ - ok: options?.ok ?? true, - status: options?.status ?? 200, - json: () => Promise.resolve(response), - text: () => Promise.resolve(JSON.stringify(response)), - }); -} - -describe('OllamaClient', () => { - let client: OllamaClient; - - beforeEach(() => { - client = new OllamaClient({ baseUrl: BASE_URL }); - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('strips trailing slashes from baseUrl', () => { - const c = new OllamaClient({ baseUrl: 'http://localhost:11434///' }); - expect(c.baseUrl).toBe('http://localhost:11434'); - }); - }); - - describe('tags', () => { - it('returns list of models', async () => { - const models = [{ name: 'llama3', size: 1000, digest: 'abc', modified_at: '2024-01-01' }]; - globalThis.fetch = mockFetch({ models }); - - const result = await client.tags(); - expect(result).toEqual(models); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/tags`, - expect.objectContaining({ - headers: expect.objectContaining({ 'Content-Type': 'application/json' }), - }) - ); - }); - - it('returns empty array when models is null', async () => { - globalThis.fetch = mockFetch({ models: null }); - const result = await client.tags(); - expect(result).toEqual([]); - }); - }); - - describe('ps', () => { - it('returns running models', async () => { - const models = [ - { name: 'llama3', size: 1000, digest: 'abc', expires_at: '2024-01-01', size_vram: 500 }, - ]; - globalThis.fetch = mockFetch({ models }); - - const result = await client.ps(); - expect(result).toEqual(models); - }); - }); - - describe('show', () => { - it('sends POST with model name', async () => { - const showData = { modelfile: '', parameters: '', template: '', details: {} }; - globalThis.fetch = mockFetch(showData); - - const result = await client.show('llama3'); - expect(result).toEqual(showData); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/show`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ name: 'llama3' }), - }) - ); - }); - }); - - describe('pull (non-streaming)', () => { - it('pulls a model', async () => { - globalThis.fetch = mockFetch({ status: 'success' }); - - const result = await client.pull('llama3'); - expect(result).toEqual({ status: 'success' }); - }); - - it('throws on failure', async () => { - globalThis.fetch = mockFetch('Pull failed', { ok: false, status: 500 }); - await expect(client.pull('bad-model')).rejects.toThrow('Ollama pull failed (500)'); - }); - }); - - describe('load', () => { - it('sends generate with keep_alive', async () => { - globalThis.fetch = mockFetch({}); - - await client.load('llama3', '15m'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/generate`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '15m' }), - }) - ); - }); - }); - - describe('unload', () => { - it('sends generate with keep_alive: 0', async () => { - globalThis.fetch = mockFetch({}); - - await client.unload('llama3'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/generate`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'llama3', prompt: '', keep_alive: '0' }), - }) - ); - }); - }); - - describe('delete', () => { - it('sends DELETE request', async () => { - globalThis.fetch = mockFetch({}); - - await client.delete('llama3'); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/delete`, - expect.objectContaining({ - method: 'DELETE', - body: JSON.stringify({ name: 'llama3' }), - }) - ); - }); - - it('throws on failure', async () => { - globalThis.fetch = mockFetch('model not found', { ok: false, status: 404 }); - await expect(client.delete('nope')).rejects.toThrow('Ollama /api/delete failed (404)'); - }); - }); - - describe('version', () => { - it('returns version string', async () => { - globalThis.fetch = mockFetch({ version: '0.5.4' }); - - const result = await client.version(); - expect(result).toBe('0.5.4'); - }); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/client.ts b/vendor/bytelyst/ollama-client/src/client.ts deleted file mode 100644 index d354948..0000000 --- a/vendor/bytelyst/ollama-client/src/client.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - OllamaClientOptions, - OllamaModel, - OllamaRunningModel, - OllamaShowResponse, - OllamaPullProgress, - OllamaVersionResponse, -} from './types.js'; -import { parseNdjsonStream } from './ndjson.js'; - -/** - * Ollama API client for model management operations. - * - * Provides typed methods for all non-streaming Ollama endpoints: - * tags, ps, show, pull, load, unload, delete, version. - */ -export class OllamaClient { - readonly baseUrl: string; - private readonly timeoutMs: number; - - constructor(options: OllamaClientOptions) { - this.baseUrl = options.baseUrl.replace(/\/+$/, ''); - this.timeoutMs = options.timeoutMs ?? 30_000; - } - - private async fetchJson(path: string, init?: RequestInit): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - - try { - const res = await fetch(`${this.baseUrl}${path}`, { - ...init, - signal: init?.signal ?? controller.signal, - headers: { 'Content-Type': 'application/json', ...init?.headers }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama ${path} failed (${res.status}): ${text.slice(0, 200)}`); - } - return (await res.json()) as T; - } finally { - clearTimeout(timeout); - } - } - - /** List all locally available models (GET /api/tags). */ - async tags(): Promise { - const data = await this.fetchJson<{ models: OllamaModel[] }>('/api/tags'); - return data.models ?? []; - } - - /** List currently running/loaded models (GET /api/ps). */ - async ps(): Promise { - const data = await this.fetchJson<{ models: OllamaRunningModel[] }>('/api/ps'); - return data.models ?? []; - } - - /** Show model details (POST /api/show). */ - async show(model: string): Promise { - return this.fetchJson('/api/show', { - method: 'POST', - body: JSON.stringify({ name: model }), - }); - } - - /** - * Pull a model from the Ollama registry (POST /api/pull). - * - * When `stream: false`, waits for the full download to complete. - * When `stream: true`, returns an async generator of progress chunks. - */ - async pull(model: string, stream?: false): Promise<{ status: string }>; - async pull(model: string, stream: true): Promise>; - async pull( - model: string, - stream: boolean = false - ): Promise<{ status: string } | AsyncGenerator> { - if (!stream) { - // Model pulls can download GBs — use 10 minute timeout instead of the default - const pullTimeoutMs = Math.max(this.timeoutMs, 600_000); - const res = await fetch(`${this.baseUrl}/api/pull`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: model, stream: false }), - signal: AbortSignal.timeout(pullTimeoutMs), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); - } - return (await res.json()) as { status: string }; - } - - // Streaming pull — return async generator (no timeout, consumer controls lifetime) - const res = await fetch(`${this.baseUrl}/api/pull`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: model, stream: true }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama pull failed (${res.status}): ${text.slice(0, 200)}`); - } - if (!res.body) throw new Error('No response body from Ollama pull'); - - return parseNdjsonStream(res.body); - } - - /** - * Load a model into memory (POST /api/generate with empty prompt + keep_alive). - * - * @param model - Model name - * @param keepAlive - How long to keep the model loaded (default: '10m') - */ - async load(model: string, keepAlive: string = '10m'): Promise { - await this.fetchJson('/api/generate', { - method: 'POST', - body: JSON.stringify({ model, prompt: '', keep_alive: keepAlive }), - }); - } - - /** - * Unload a model from memory (POST /api/generate with keep_alive: '0'). - */ - async unload(model: string): Promise { - await this.fetchJson('/api/generate', { - method: 'POST', - body: JSON.stringify({ model, prompt: '', keep_alive: '0' }), - }); - } - - /** Delete a model (DELETE /api/delete). */ - async delete(model: string): Promise { - await this.fetchJson('/api/delete', { - method: 'DELETE', - body: JSON.stringify({ name: model }), - }); - } - - /** Get Ollama server version (GET /api/version). */ - async version(): Promise { - const data = await this.fetchJson('/api/version'); - return data.version; - } -} diff --git a/vendor/bytelyst/ollama-client/src/config.test.ts b/vendor/bytelyst/ollama-client/src/config.test.ts deleted file mode 100644 index fe2fb4a..0000000 --- a/vendor/bytelyst/ollama-client/src/config.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolveOllamaUrl } from './config.js'; - -describe('resolveOllamaUrl', () => { - it('returns default localhost when no env vars set', () => { - expect(resolveOllamaUrl({})).toBe('http://localhost:11434'); - }); - - it('uses OLLAMA_URL when set', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434' })).toBe('http://myhost:11434'); - }); - - it('uses OLLAMA_HOST when set', () => { - expect(resolveOllamaUrl({ OLLAMA_HOST: 'http://otherhost:11434' })).toBe( - 'http://otherhost:11434' - ); - }); - - it('prefers OLLAMA_URL over OLLAMA_HOST', () => { - expect( - resolveOllamaUrl({ - OLLAMA_URL: 'http://primary:11434', - OLLAMA_HOST: 'http://secondary:11434', - }) - ).toBe('http://primary:11434'); - }); - - it('normalizes URL without scheme', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'myhost:11434' })).toBe('http://myhost:11434'); - }); - - it('strips trailing slashes', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'http://myhost:11434///' })).toBe('http://myhost:11434'); - }); - - it('trims whitespace', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: ' http://myhost:11434 ' })).toBe('http://myhost:11434'); - }); - - it('preserves https scheme', () => { - expect(resolveOllamaUrl({ OLLAMA_URL: 'https://secure:11434' })).toBe('https://secure:11434'); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/config.ts b/vendor/bytelyst/ollama-client/src/config.ts deleted file mode 100644 index a34db87..0000000 --- a/vendor/bytelyst/ollama-client/src/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { execSync } from 'child_process'; -import fs from 'fs'; - -function normalizeUrl(input: string): string { - const trimmed = input.trim().replace(/\/+$/, ''); - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - return `http://${trimmed}`; -} - -function detectWslGatewayOllamaUrl(): string | null { - try { - if (process.platform !== 'linux') return null; - const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); - if (!version.includes('microsoft')) return null; - - const gw = execSync("ip route show default | awk '{print $3}' | head -1", { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (!gw) return null; - return `http://${gw}:11434`; - } catch { - return null; - } -} - -/** - * Resolve the Ollama base URL from environment variables with WSL2 fallback. - * - * Priority: - * 1. `OLLAMA_URL` or `OLLAMA_HOST` env var (explicit config) - * 2. WSL2 gateway IP (Windows-hosted Ollama detected via /proc/version) - * 3. `http://localhost:11434` (default) - * - * @param env - Environment variables object (defaults to `process.env`) - */ -export function resolveOllamaUrl(env: Record = process.env): string { - const explicit = env.OLLAMA_URL || env.OLLAMA_HOST; - if (explicit) return normalizeUrl(explicit); - - const inferred = detectWslGatewayOllamaUrl(); - if (inferred) return inferred; - - return 'http://localhost:11434'; -} diff --git a/vendor/bytelyst/ollama-client/src/embed.test.ts b/vendor/bytelyst/ollama-client/src/embed.test.ts deleted file mode 100644 index 48ffe94..0000000 --- a/vendor/bytelyst/ollama-client/src/embed.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getEmbedding, getEmbeddingVector } from './embed.js'; - -const BASE_URL = 'http://localhost:11434'; - -describe('getEmbedding', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns embedding response', async () => { - const response = { model: 'nomic-embed-text', embeddings: [[0.1, 0.2, 0.3]] }; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(response), - }); - - const result = await getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }); - expect(result).toEqual(response); - expect(globalThis.fetch).toHaveBeenCalledWith( - `${BASE_URL}/api/embed`, - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ model: 'nomic-embed-text', input: 'hello' }), - }) - ); - }); - - it('throws on error response', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('internal error'), - }); - - await expect( - getEmbedding(BASE_URL, { model: 'nomic-embed-text', input: 'hello' }) - ).rejects.toThrow('Ollama embed failed (500)'); - }); -}); - -describe('getEmbeddingVector', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns first embedding vector', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [[0.1, 0.2]] }), - }); - - const result = await getEmbeddingVector(BASE_URL, 'hello'); - expect(result).toEqual([0.1, 0.2]); - }); - - it('returns empty array when no embeddings', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ model: 'nomic-embed-text', embeddings: [] }), - }); - - const result = await getEmbeddingVector(BASE_URL, 'hello'); - expect(result).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/embed.ts b/vendor/bytelyst/ollama-client/src/embed.ts deleted file mode 100644 index 7578c12..0000000 --- a/vendor/bytelyst/ollama-client/src/embed.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { OllamaEmbedOptions, OllamaEmbeddingResponse } from './types.js'; - -/** - * Get embeddings for text using an Ollama embedding model. - * - * @param baseUrl - Ollama server base URL - * @param options - Embedding options (model, input text) - * @returns Array of embedding vectors (one per input string) - */ -export async function getEmbedding( - baseUrl: string, - options: OllamaEmbedOptions -): Promise { - const res = await fetch(`${baseUrl}/api/embed`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: options.model, - input: options.input, - ...(options.options && { options: options.options }), - }), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama embed failed (${res.status}): ${text.slice(0, 200)}`); - } - - return (await res.json()) as OllamaEmbeddingResponse; -} - -/** - * Convenience: get a single embedding vector for a text string. - * - * @param baseUrl - Ollama server base URL - * @param text - Text to embed - * @param model - Embedding model name (default: 'nomic-embed-text') - * @returns Single embedding vector - */ -export async function getEmbeddingVector( - baseUrl: string, - text: string, - model: string = 'nomic-embed-text' -): Promise { - const response = await getEmbedding(baseUrl, { model, input: text }); - return response.embeddings?.[0] ?? []; -} diff --git a/vendor/bytelyst/ollama-client/src/format.test.ts b/vendor/bytelyst/ollama-client/src/format.test.ts deleted file mode 100644 index 3011147..0000000 --- a/vendor/bytelyst/ollama-client/src/format.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; - -describe('formatBytes', () => { - it('formats 0 bytes', () => { - expect(formatBytes(0)).toBe('0 B'); - }); - - it('formats bytes', () => { - expect(formatBytes(512)).toBe('512 B'); - }); - - it('formats kilobytes', () => { - expect(formatBytes(1536)).toBe('1.5 KB'); - }); - - it('formats megabytes', () => { - expect(formatBytes(5242880)).toBe('5 MB'); - }); - - it('formats gigabytes', () => { - expect(formatBytes(4294967296)).toBe('4 GB'); - }); - - it('returns 0 B for negative input', () => { - expect(formatBytes(-100)).toBe('0 B'); - }); -}); - -describe('estimateTokens', () => { - it('returns 0 for empty string', () => { - expect(estimateTokens('')).toBe(0); - }); - - it('returns 0 for whitespace-only string', () => { - expect(estimateTokens(' ')).toBe(0); - }); - - it('estimates tokens for a sentence', () => { - const result = estimateTokens('Hello world this is a test'); - expect(result).toBeGreaterThan(0); - // 6 words × 1.3 = 7.8, ceil = 8 - expect(result).toBe(8); - }); -}); - -describe('getModelContextWindow', () => { - it('returns 128k for models with 128k in name', () => { - expect(getModelContextWindow('llama3-128k')).toBe(128_000); - }); - - it('returns 32k for models with 32k in name', () => { - expect(getModelContextWindow('gpt4-32k')).toBe(32_000); - }); - - it('returns 4096 for models without size marker', () => { - expect(getModelContextWindow('llama3:latest')).toBe(4_096); - }); -}); - -describe('formatUptime', () => { - it('formats minutes only', () => { - expect(formatUptime(120)).toBe('2m'); - }); - - it('formats hours and minutes', () => { - expect(formatUptime(3661)).toBe('1h 1m'); - }); - - it('formats days, hours, and minutes', () => { - expect(formatUptime(90061)).toBe('1d 1h 1m'); - }); - - it('formats zero', () => { - expect(formatUptime(0)).toBe('0m'); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/format.ts b/vendor/bytelyst/ollama-client/src/format.ts deleted file mode 100644 index c252ed0..0000000 --- a/vendor/bytelyst/ollama-client/src/format.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Format a byte count into a human-readable string. - * - * @example formatBytes(1536) // '1.5 KB' - * @example formatBytes(0) // '0 B' - */ -export function formatBytes(bytes: number): string { - if (bytes <= 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -/** - * Approximate token count for a text string. - * - * Uses a word-count × 1.3 heuristic (typical for English text with LLM tokenizers). - */ -export function estimateTokens(text: string): number { - const trimmed = text.trim(); - if (!trimmed) return 0; - return Math.ceil(trimmed.split(/\s+/).length * 1.3); -} - -/** - * Best-effort model context window lookup based on model name. - * - * Checks for common context window markers in the model name string. - * Falls back to 4096 if no marker is found. - */ -export function getModelContextWindow(modelName: string): number { - const n = modelName.toLowerCase(); - if (n.includes('128k')) return 128_000; - if (n.includes('64k')) return 64_000; - if (n.includes('32k')) return 32_000; - if (n.includes('16k')) return 16_000; - if (n.includes('8k')) return 8_000; - return 4_096; -} - -/** - * Format a duration in seconds to a human-readable uptime string. - * - * @example formatUptime(90061) // '1d 1h 1m' - * @example formatUptime(3661) // '1h 1m' - * @example formatUptime(120) // '2m' - */ -export function formatUptime(seconds: number): string { - const d = Math.floor(seconds / 86400); - const h = Math.floor((seconds % 86400) / 3600); - const m = Math.floor((seconds % 3600) / 60); - if (d > 0) return `${d}d ${h}h ${m}m`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} diff --git a/vendor/bytelyst/ollama-client/src/health.test.ts b/vendor/bytelyst/ollama-client/src/health.test.ts deleted file mode 100644 index c1b7161..0000000 --- a/vendor/bytelyst/ollama-client/src/health.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { checkHealth, checkHealthDetailed } from './health.js'; - -const BASE_URL = 'http://localhost:11434'; - -describe('checkHealth', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns true when server is healthy', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); - expect(await checkHealth(BASE_URL)).toBe(true); - }); - - it('returns false when server returns non-ok', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }); - expect(await checkHealth(BASE_URL)).toBe(false); - }); - - it('returns false when fetch throws', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); - expect(await checkHealth(BASE_URL)).toBe(false); - }); -}); - -describe('checkHealthDetailed', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('returns online + version when server is healthy', async () => { - globalThis.fetch = vi.fn().mockImplementation((url: string) => { - if (url.includes('/api/tags')) return Promise.resolve({ ok: true }); - if (url.includes('/api/version')) - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ version: '0.5.4' }), - }); - return Promise.reject(new Error('unexpected')); - }); - - const result = await checkHealthDetailed(BASE_URL); - expect(result).toEqual({ online: true, version: '0.5.4', url: BASE_URL }); - }); - - it('returns offline when server is unreachable', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); - - const result = await checkHealthDetailed(BASE_URL); - expect(result).toEqual({ online: false, version: null, url: BASE_URL }); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/health.ts b/vendor/bytelyst/ollama-client/src/health.ts deleted file mode 100644 index da3410b..0000000 --- a/vendor/bytelyst/ollama-client/src/health.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Check if the Ollama server is reachable. - * - * @param baseUrl - Ollama server base URL - * @param timeoutMs - Timeout in milliseconds (default: 3000) - * @returns `true` if the server responds to GET /api/tags within the timeout - */ -export async function checkHealth(baseUrl: string, timeoutMs: number = 3000): Promise { - try { - const res = await fetch(`${baseUrl}/api/tags`, { - signal: AbortSignal.timeout(timeoutMs), - }); - return res.ok; - } catch { - return false; - } -} - -/** - * Check health and return structured result with version info. - */ -export async function checkHealthDetailed( - baseUrl: string, - timeoutMs: number = 3000 -): Promise<{ online: boolean; version: string | null; url: string }> { - try { - const [tagsRes, versionRes] = await Promise.allSettled([ - fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) }), - fetch(`${baseUrl}/api/version`, { signal: AbortSignal.timeout(timeoutMs) }), - ]); - - const online = tagsRes.status === 'fulfilled' && tagsRes.value.ok; - let version: string | null = null; - - if (versionRes.status === 'fulfilled' && versionRes.value.ok) { - const data = (await versionRes.value.json()) as { version?: string }; - version = data.version ?? null; - } - - return { online, version, url: baseUrl }; - } catch { - return { online: false, version: null, url: baseUrl }; - } -} diff --git a/vendor/bytelyst/ollama-client/src/index.ts b/vendor/bytelyst/ollama-client/src/index.ts deleted file mode 100644 index 1393e4d..0000000 --- a/vendor/bytelyst/ollama-client/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Types -export type { - OllamaModel, - OllamaModelDetails, - OllamaRunningModel, - OllamaChatMessage, - OllamaStreamChunk, - OllamaGenerateChunk, - OllamaEmbeddingResponse, - OllamaShowResponse, - OllamaPullProgress, - OllamaVersionResponse, - OllamaClientOptions, - OllamaChatOptions, - OllamaGenerateOptions, - OllamaEmbedOptions, -} from './types.js'; - -// Config -export { resolveOllamaUrl } from './config.js'; - -// Client -export { OllamaClient } from './client.js'; - -// Streaming -export { streamChat, streamGenerate } from './stream.js'; - -// Embeddings -export { getEmbedding, getEmbeddingVector } from './embed.js'; - -// Health -export { checkHealth, checkHealthDetailed } from './health.js'; - -// NDJSON parser -export { parseNdjsonStream } from './ndjson.js'; - -// Client-side stream consumers -export { consumeSSEStream, consumeNdjsonStream } from './client-parsers.js'; - -// Format utilities -export { formatBytes, estimateTokens, getModelContextWindow, formatUptime } from './format.js'; diff --git a/vendor/bytelyst/ollama-client/src/ndjson.test.ts b/vendor/bytelyst/ollama-client/src/ndjson.test.ts deleted file mode 100644 index 41be1d4..0000000 --- a/vendor/bytelyst/ollama-client/src/ndjson.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseNdjsonStream } from './ndjson.js'; - -function createReadableStream(chunks: string[]): ReadableStream { - const encoder = new TextEncoder(); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - controller.enqueue(encoder.encode(chunks[index])); - index++; - } else { - controller.close(); - } - }, - }); -} - -describe('parseNdjsonStream', () => { - it('parses complete NDJSON lines', async () => { - const stream = createReadableStream(['{"a":1}\n{"a":2}\n{"a":3}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); - }); - - it('handles partial lines split across chunks', async () => { - const stream = createReadableStream(['{"a":', '1}\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('skips empty lines', async () => { - const stream = createReadableStream(['{"a":1}\n\n\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('skips malformed JSON lines', async () => { - const stream = createReadableStream(['{"a":1}\nnot json\n{"a":2}\n']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('processes remaining buffer after stream ends', async () => { - const stream = createReadableStream(['{"a":1}\n{"a":2}']); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([{ a: 1 }, { a: 2 }]); - }); - - it('handles empty stream', async () => { - const stream = createReadableStream([]); - const results: unknown[] = []; - for await (const chunk of parseNdjsonStream(stream)) { - results.push(chunk); - } - expect(results).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/ndjson.ts b/vendor/bytelyst/ollama-client/src/ndjson.ts deleted file mode 100644 index 7642649..0000000 --- a/vendor/bytelyst/ollama-client/src/ndjson.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Parse an NDJSON (newline-delimited JSON) stream into an async generator. - * - * Works with both Node.js `ReadableStream` and browser `ReadableStream`. - * Handles partial lines across chunk boundaries gracefully. - */ -export async function* parseNdjsonStream( - body: ReadableStream -): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - yield JSON.parse(trimmed) as T; - } catch { - // skip malformed lines - } - } - } - - // Process remaining buffer after stream ends - if (buffer.trim()) { - try { - yield JSON.parse(buffer.trim()) as T; - } catch { - // skip - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/vendor/bytelyst/ollama-client/src/stream.test.ts b/vendor/bytelyst/ollama-client/src/stream.test.ts deleted file mode 100644 index 905d9f1..0000000 --- a/vendor/bytelyst/ollama-client/src/stream.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { streamChat, streamGenerate } from './stream.js'; - -const BASE_URL = 'http://localhost:11434'; - -function createNdjsonResponse(chunks: object[]): Response { - const encoder = new TextEncoder(); - const ndjson = chunks.map(c => JSON.stringify(c)).join('\n') + '\n'; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(ndjson)); - controller.close(); - }, - }); - return { ok: true, status: 200, body: stream } as unknown as Response; -} - -describe('streamChat', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('yields chat stream chunks', async () => { - const chunks = [ - { model: 'llama3', message: { role: 'assistant', content: 'Hello' }, done: false }, - { model: 'llama3', message: { role: 'assistant', content: ' world' }, done: false }, - { model: 'llama3', message: { role: 'assistant', content: '' }, done: true, eval_count: 10 }, - ]; - globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); - - const results = []; - for await (const chunk of streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - })) { - results.push(chunk); - } - - expect(results).toHaveLength(3); - expect(results[0].message.content).toBe('Hello'); - expect(results[2].done).toBe(true); - expect(results[2].eval_count).toBe(10); - }); - - it('throws on non-ok response', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve('internal error'), - }); - - const gen = streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - }); - await expect(gen.next()).rejects.toThrow('Ollama chat failed (500)'); - }); - - it('throws when response has no body', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - body: null, - }); - - const gen = streamChat(BASE_URL, { - model: 'llama3', - messages: [{ role: 'user', content: 'Hi' }], - }); - await expect(gen.next()).rejects.toThrow('No response body'); - }); -}); - -describe('streamGenerate', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it('yields generate stream chunks', async () => { - const chunks = [ - { model: 'llama3', response: 'Hello', done: false }, - { model: 'llama3', response: ' world', done: false }, - { model: 'llama3', response: '', done: true, eval_count: 5 }, - ]; - globalThis.fetch = vi.fn().mockResolvedValue(createNdjsonResponse(chunks)); - - const results = []; - for await (const chunk of streamGenerate(BASE_URL, { - model: 'llama3', - prompt: 'Say hello', - })) { - results.push(chunk); - } - - expect(results).toHaveLength(3); - expect(results[0].response).toBe('Hello'); - expect(results[2].done).toBe(true); - }); -}); diff --git a/vendor/bytelyst/ollama-client/src/stream.ts b/vendor/bytelyst/ollama-client/src/stream.ts deleted file mode 100644 index 5851d4a..0000000 --- a/vendor/bytelyst/ollama-client/src/stream.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { - OllamaChatOptions, - OllamaGenerateOptions, - OllamaStreamChunk, - OllamaGenerateChunk, -} from './types.js'; -import { parseNdjsonStream } from './ndjson.js'; - -/** - * Stream a chat completion from Ollama. - * - * Yields NDJSON chunks from `POST /api/chat` as an async generator. - * - * @param baseUrl - Ollama server base URL - * @param options - Chat options (model, messages, signal, etc.) - */ -export async function* streamChat( - baseUrl: string, - options: OllamaChatOptions -): AsyncGenerator { - const { model, messages, signal, ...rest } = options; - - const body: Record = { model, messages, stream: true }; - if (rest.options) body.options = rest.options; - if (rest.format) body.format = rest.format; - if (rest.keep_alive) body.keep_alive = rest.keep_alive; - - const res = await fetch(`${baseUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama chat failed (${res.status}): ${text.slice(0, 200)}`); - } - - if (!res.body) { - throw new Error('No response body from Ollama'); - } - - yield* parseNdjsonStream(res.body); -} - -/** - * Stream a text generation from Ollama. - * - * Yields NDJSON chunks from `POST /api/generate` as an async generator. - * - * @param baseUrl - Ollama server base URL - * @param options - Generate options (model, prompt, signal, etc.) - */ -export async function* streamGenerate( - baseUrl: string, - options: OllamaGenerateOptions -): AsyncGenerator { - const { model, prompt, signal, ...rest } = options; - - const body: Record = { model, prompt, stream: true }; - if (rest.system) body.system = rest.system; - if (rest.options) body.options = rest.options; - if (rest.format) body.format = rest.format; - if (rest.keep_alive) body.keep_alive = rest.keep_alive; - if (rest.context) body.context = rest.context; - - const res = await fetch(`${baseUrl}/api/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Ollama generate failed (${res.status}): ${text.slice(0, 200)}`); - } - - if (!res.body) { - throw new Error('No response body from Ollama'); - } - - yield* parseNdjsonStream(res.body); -} diff --git a/vendor/bytelyst/ollama-client/src/types.ts b/vendor/bytelyst/ollama-client/src/types.ts deleted file mode 100644 index 410d89d..0000000 --- a/vendor/bytelyst/ollama-client/src/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -// --- Model types --- - -export interface OllamaModelDetails { - family?: string; - families?: string[]; - format?: string; - parameter_size?: string; - quantization_level?: string; -} - -export interface OllamaModel { - name: string; - model?: string; - size: number; - digest: string; - modified_at: string; - details?: OllamaModelDetails; -} - -export interface OllamaRunningModel { - name: string; - model?: string; - size: number; - digest: string; - expires_at: string; - size_vram: number; - details?: OllamaModelDetails; -} - -// --- Chat types --- - -export interface OllamaChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - images?: string[]; -} - -export interface OllamaStreamChunk { - model: string; - message: { role: string; content: string }; - done: boolean; - total_duration?: number; - eval_count?: number; - eval_duration?: number; - prompt_eval_count?: number; - prompt_eval_duration?: number; - load_duration?: number; -} - -// --- Generate types --- - -export interface OllamaGenerateChunk { - model: string; - response: string; - done: boolean; - total_duration?: number; - eval_count?: number; - eval_duration?: number; - prompt_eval_count?: number; - prompt_eval_duration?: number; - load_duration?: number; - context?: number[]; -} - -// --- Embedding types --- - -export interface OllamaEmbeddingResponse { - model: string; - embeddings: number[][]; - total_duration?: number; - load_duration?: number; - prompt_eval_count?: number; -} - -// --- Show types --- - -export interface OllamaShowResponse { - modelfile: string; - parameters: string; - template: string; - details: OllamaModelDetails; - model_info?: Record; -} - -// --- Pull progress types --- - -export interface OllamaPullProgress { - status: string; - digest?: string; - total?: number; - completed?: number; -} - -// --- Version types --- - -export interface OllamaVersionResponse { - version: string; -} - -// --- Client options --- - -export interface OllamaClientOptions { - /** Base URL of the Ollama server (e.g. http://localhost:11434) */ - baseUrl: string; - /** Default request timeout in milliseconds (default: 30000) */ - timeoutMs?: number; -} - -export interface OllamaChatOptions { - model: string; - messages: OllamaChatMessage[]; - signal?: AbortSignal; - options?: Record; - format?: 'json' | string; - keep_alive?: string; -} - -export interface OllamaGenerateOptions { - model: string; - prompt: string; - signal?: AbortSignal; - system?: string; - options?: Record; - format?: 'json' | string; - keep_alive?: string; - context?: number[]; -} - -export interface OllamaEmbedOptions { - model: string; - input: string | string[]; - options?: Record; -} diff --git a/vendor/bytelyst/ollama-client/tsconfig.json b/vendor/bytelyst/ollama-client/tsconfig.json deleted file mode 100644 index b74148d..0000000 --- a/vendor/bytelyst/ollama-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "types": ["node"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/ollama-client/vitest.config.ts b/vendor/bytelyst/ollama-client/vitest.config.ts deleted file mode 100644 index 811c18a..0000000 --- a/vendor/bytelyst/ollama-client/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - passWithNoTests: true, - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/org-client/package.json b/vendor/bytelyst/org-client/package.json deleted file mode 100644 index a12b5f1..0000000 --- a/vendor/bytelyst/org-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/org-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/org-client/src/client.test.ts b/vendor/bytelyst/org-client/src/client.test.ts deleted file mode 100644 index 4dbc8cc..0000000 --- a/vendor/bytelyst/org-client/src/client.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createOrgClient } from './client.js'; -import type { OrganizationDoc, WorkspaceDoc, MembershipDoc, LicenseDoc } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'admin-token', -}; - -function mockOrg(overrides?: Partial): OrganizationDoc { - return { - id: 'org_1', - productId: 'testapp', - name: 'Test Org', - slug: 'test-org', - status: 'active', - ownerUserId: 'user-1', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockWorkspace(overrides?: Partial): WorkspaceDoc { - return { - id: 'ws_1', - orgId: 'org_1', - productId: 'testapp', - name: 'Engineering', - slug: 'engineering', - status: 'active', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockMembership(overrides?: Partial): MembershipDoc { - return { - id: 'mbr_org1_user1_org', - orgId: 'org_1', - productId: 'testapp', - scope: 'org', - userId: 'user-1', - role: 'admin', - status: 'active', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockLicense(overrides?: Partial): LicenseDoc { - return { - id: 'lic_1', - productId: 'testapp', - key: 'LYSNR-XXXX-YYYY-ZZZZ', - userId: 'user-1', - plan: 'pro', - status: 'active', - activatedAt: '2026-01-01T00:00:00Z', - expiresAt: null, - deviceIds: ['device-1'], - maxDevices: 3, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -describe('createOrgClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should list orgs', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockOrg()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listOrgs(); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Test Org'); - }); - - it('should create an org', async () => { - const org = mockOrg(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(org), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.createOrg({ name: 'Test Org', slug: 'test-org' }); - expect(result.id).toBe('org_1'); - }); - - it('should get an org by id', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockOrg()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.getOrg('org_1'); - expect(result.slug).toBe('test-org'); - }); - - it('should list workspaces for an org', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockWorkspace()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listWorkspaces('org_1'); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('Engineering'); - }); - - it('should create a workspace', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockWorkspace()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.createWorkspace('org_1', { - name: 'Engineering', - slug: 'engineering', - }); - expect(result.orgId).toBe('org_1'); - }); - - it('should list memberships', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([mockMembership()]), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.listMemberships('org_1'); - expect(result).toHaveLength(1); - expect(result[0].role).toBe('admin'); - }); - - it('should add a member', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockMembership()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.addMember('org_1', { userId: 'user-1', role: 'admin' }); - expect(result.userId).toBe('user-1'); - }); - - it('should generate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockLicense()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.generateLicense({ userId: 'user-1', plan: 'pro' }); - expect(result.key).toContain('LYSNR'); - }); - - it('should activate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockLicense()), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.activateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }); - expect(result.status).toBe('active'); - }); - - it('should update an org', async () => { - const org = mockOrg({ name: 'Updated Org' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(org), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.updateOrg('org_1', { name: 'Updated Org' }); - expect(result.name).toBe('Updated Org'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/orgs/org_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should update a workspace', async () => { - const ws = mockWorkspace({ name: 'Renamed' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ws), - }) - ); - const client = createOrgClient(baseConfig); - const result = await client.updateWorkspace('org_1', 'ws_1', { name: 'Renamed' }); - expect(result.name).toBe('Renamed'); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/orgs/org_1/workspaces/ws_1', - expect.objectContaining({ method: 'PATCH' }) - ); - }); - - it('should deactivate a license', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - }) - ); - const client = createOrgClient(baseConfig); - await expect( - client.deactivateLicense({ key: 'LYSNR-XXXX', deviceId: 'device-1' }) - ).resolves.toBeUndefined(); - - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/licenses/deactivate', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should throw 403 for non-admin', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 403, - }) - ); - const client = createOrgClient(baseConfig); - await expect(client.listOrgs()).rejects.toThrow('listOrgs failed: 403'); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([]), - }) - ); - const client = createOrgClient(baseConfig); - await client.listOrgs(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer admin-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/org-client/src/client.ts b/vendor/bytelyst/org-client/src/client.ts deleted file mode 100644 index a2135ee..0000000 --- a/vendor/bytelyst/org-client/src/client.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Browser/React Native-safe org, workspace, membership, and license client - * for platform-service. - * - * All org routes require admin-only access (super_admin or admin JWT role). - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - LicenseDoc, - MembershipDoc, - OrgClient, - OrgClientConfig, - OrganizationDoc, - WorkspaceDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createOrgClient(config: OrgClientConfig): OrgClient { - const { baseUrl, productId, getAccessToken } = config; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - // ── Organizations ───────────────────────────────── - - async function listOrgs(query?: { status?: string; limit?: number }): Promise { - const params = new URLSearchParams(); - if (query?.status) params.set('status', query.status); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs ? `${baseUrl}/orgs?${qs}` : `${baseUrl}/orgs`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listOrgs failed: ${res.status}`); - return (await res.json()) as OrganizationDoc[]; - } - - async function createOrg(input: { - name: string; - slug: string; - ownerUserId?: string; - }): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ ...input, productId }), - }); - if (!res.ok) throw new Error(`createOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - async function getOrg(id: string): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { - headers: headers(), - }); - if (!res.ok) throw new Error(`getOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - async function updateOrg( - id: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error(`updateOrg failed: ${res.status}`); - return (await res.json()) as OrganizationDoc; - } - - // ── Workspaces ──────────────────────────────────── - - async function listWorkspaces(orgId: string): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { - headers: headers(), - }); - if (!res.ok) throw new Error(`listWorkspaces failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc[]; - } - - async function createWorkspace( - orgId: string, - input: { name: string; slug: string; description?: string } - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`createWorkspace failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc; - } - - async function updateWorkspace( - orgId: string, - workspaceId: string, - updates: Partial - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/orgs/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(workspaceId)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateWorkspace failed: ${res.status}`); - return (await res.json()) as WorkspaceDoc; - } - - // ── Memberships ─────────────────────────────────── - - async function listMemberships( - orgId: string, - query?: { scope?: string; limit?: number } - ): Promise { - const params = new URLSearchParams(); - if (query?.scope) params.set('scope', query.scope); - if (query?.limit) params.set('limit', String(query.limit)); - const qs = params.toString(); - const url = qs - ? `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships?${qs}` - : `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`; - const res = await globalThis.fetch(url, { headers: headers() }); - if (!res.ok) throw new Error(`listMemberships failed: ${res.status}`); - return (await res.json()) as MembershipDoc[]; - } - - async function addMember( - orgId: string, - input: { userId: string; role?: string; scope?: string; workspaceId?: string } - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`addMember failed: ${res.status}`); - return (await res.json()) as MembershipDoc; - } - - async function updateMember( - orgId: string, - membershipId: string, - updates: { role?: string; status?: string } - ): Promise { - const res = await globalThis.fetch( - `${baseUrl}/orgs/${encodeURIComponent(orgId)}/memberships/${encodeURIComponent(membershipId)}`, - { - method: 'PATCH', - headers: headers(), - body: JSON.stringify(updates), - } - ); - if (!res.ok) throw new Error(`updateMember failed: ${res.status}`); - return (await res.json()) as MembershipDoc; - } - - // ── Licenses ────────────────────────────────────── - - async function generateLicense(input: { - userId: string; - plan: string; - maxDevices?: number; - }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ ...input, productId }), - }); - if (!res.ok) throw new Error(`generateLicense failed: ${res.status}`); - return (await res.json()) as LicenseDoc; - } - - async function activateLicense(input: { key: string; deviceId: string }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses/activate`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`activateLicense failed: ${res.status}`); - return (await res.json()) as LicenseDoc; - } - - async function deactivateLicense(input: { key: string; deviceId: string }): Promise { - const res = await globalThis.fetch(`${baseUrl}/licenses/deactivate`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error(`deactivateLicense failed: ${res.status}`); - } - - return { - listOrgs, - createOrg, - getOrg, - updateOrg, - listWorkspaces, - createWorkspace, - updateWorkspace, - listMemberships, - addMember, - updateMember, - generateLicense, - activateLicense, - deactivateLicense, - }; -} diff --git a/vendor/bytelyst/org-client/src/index.ts b/vendor/bytelyst/org-client/src/index.ts deleted file mode 100644 index 2eaba2e..0000000 --- a/vendor/bytelyst/org-client/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export interface OrgClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface OrgDoc { - id: string; - name: string; - slug: string; - memberCount: number; - plan: string; - metadata?: Record; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: OrgClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createOrgClient(opts: OrgClientOptions) { - const { baseUrl } = opts; - - return { - async listOrgs(): Promise { - const res = await fetch(joinUrl(baseUrl, "/organizations"), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - async getOrg(orgId: string): Promise { - const res = await fetch( - joinUrl(baseUrl, `/organizations/${encodeURIComponent(orgId)}`), - { method: "GET", headers: headers(opts) } - ); - return parseJson(res); - }, - }; -} diff --git a/vendor/bytelyst/org-client/src/types.ts b/vendor/bytelyst/org-client/src/types.ts deleted file mode 100644 index 4ac9a86..0000000 --- a/vendor/bytelyst/org-client/src/types.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Types for @bytelyst/org-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface OrganizationDoc { - id: string; - productId: string; - name: string; - slug: string; - status: 'active' | 'disabled'; - ownerUserId: string; - metadata?: Record; - createdAt: string; - updatedAt: string; -} - -export interface WorkspaceDoc { - id: string; - orgId: string; - productId: string; - name: string; - slug: string; - status: 'active' | 'archived'; - description?: string; - metadata?: Record; - createdAt: string; - updatedAt: string; -} - -export interface MembershipDoc { - id: string; - orgId: string; - productId: string; - scope: 'org' | 'workspace'; - workspaceId?: string; - userId: string; - role: 'owner' | 'admin' | 'member' | 'viewer'; - status: 'active' | 'invited' | 'disabled'; - invitedBy?: string; - createdAt: string; - updatedAt: string; -} - -export interface LicenseDoc { - id: string; - productId: string; - key: string; - userId: string; - plan: 'free' | 'pro' | 'enterprise'; - status: 'active' | 'revoked' | 'expired'; - activatedAt: string | null; - expiresAt: string | null; - deviceIds: string[]; - maxDevices: number; - createdAt: string; - updatedAt: string; -} - -export interface OrgClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token (admin role required), or null. */ - getAccessToken: () => string | null; -} - -export interface OrgClient { - // Organizations - listOrgs(query?: { status?: string; limit?: number }): Promise; - createOrg(input: { name: string; slug: string; ownerUserId?: string }): Promise; - getOrg(id: string): Promise; - updateOrg(id: string, updates: Partial): Promise; - - // Workspaces - listWorkspaces(orgId: string): Promise; - createWorkspace( - orgId: string, - input: { name: string; slug: string; description?: string } - ): Promise; - updateWorkspace( - orgId: string, - workspaceId: string, - updates: Partial - ): Promise; - - // Memberships - listMemberships( - orgId: string, - query?: { scope?: string; limit?: number } - ): Promise; - addMember( - orgId: string, - input: { userId: string; role?: string; scope?: string; workspaceId?: string } - ): Promise; - updateMember( - orgId: string, - membershipId: string, - updates: { role?: string; status?: string } - ): Promise; - - // Licenses - generateLicense(input: { - userId: string; - plan: string; - maxDevices?: number; - }): Promise; - activateLicense(input: { key: string; deviceId: string }): Promise; - deactivateLicense(input: { key: string; deviceId: string }): Promise; -} diff --git a/vendor/bytelyst/org-client/tsconfig.json b/vendor/bytelyst/org-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/org-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/palace/package.json b/vendor/bytelyst/palace/package.json deleted file mode 100644 index 813a38b..0000000 --- a/vendor/bytelyst/palace/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@bytelyst/palace", - "version": "0.1.4", - "description": "Shared MemPalace primitives — types, cosine similarity, dedup, relevance decay, extraction prompts, KG helpers, wake-up context builder", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "zod": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/palace/src/__tests__/cosine.test.ts b/vendor/bytelyst/palace/src/__tests__/cosine.test.ts deleted file mode 100644 index 4d8a449..0000000 --- a/vendor/bytelyst/palace/src/__tests__/cosine.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { cosineSimilarity, normalizeVector, topKByCosine } from '../cosine.js'; - -describe('cosineSimilarity', () => { - it('returns 1.0 for identical vectors', () => { - expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1.0); - }); - - it('returns 0.0 for orthogonal vectors', () => { - expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0); - }); - - it('returns -1.0 for opposite vectors', () => { - expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0); - }); - - it('returns 0 for empty vectors', () => { - expect(cosineSimilarity([], [])).toBe(0); - }); - - it('returns 0 for mismatched dimensions', () => { - expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0); - }); - - it('returns 0 for zero vector', () => { - expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0); - }); - - it('computes correct similarity for arbitrary vectors', () => { - const a = [1, 2, 3]; - const b = [4, 5, 6]; - // Known value: (4+10+18) / (sqrt(14)*sqrt(77)) ≈ 0.9746 - expect(cosineSimilarity(a, b)).toBeCloseTo(0.9746, 3); - }); -}); - -describe('normalizeVector', () => { - it('normalizes to unit length', () => { - const v = normalizeVector([3, 4]); - const magnitude = Math.sqrt(v[0] ** 2 + v[1] ** 2); - expect(magnitude).toBeCloseTo(1.0); - }); - - it('returns zero vector for zero input', () => { - expect(normalizeVector([0, 0, 0])).toEqual([0, 0, 0]); - }); - - it('preserves direction', () => { - const v = normalizeVector([2, 0, 0]); - expect(v[0]).toBeCloseTo(1); - expect(v[1]).toBeCloseTo(0); - expect(v[2]).toBeCloseTo(0); - }); -}); - -describe('topKByCosine', () => { - const items = [ - { id: 'a', emb: [1, 0, 0] }, - { id: 'b', emb: [0, 1, 0] }, - { id: 'c', emb: [0.9, 0.1, 0] }, - { id: 'd', emb: undefined as number[] | undefined }, - ]; - - it('returns top-K sorted by score', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 2); - expect(results).toHaveLength(2); - expect(results[0].item.id).toBe('a'); - expect(results[0].score).toBeCloseTo(1.0); - expect(results[1].item.id).toBe('c'); - }); - - it('skips items with missing embeddings', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 10); - expect(results).toHaveLength(3); // 'd' skipped - }); - - it('respects minScore filter', () => { - const results = topKByCosine([1, 0, 0], items, i => i.emb, 10, 0.5); - expect(results.every(r => r.score >= 0.5)).toBe(true); - }); - - it('returns empty array for empty items', () => { - const results = topKByCosine([1, 0, 0], [], () => undefined, 5); - expect(results).toEqual([]); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/decay.test.ts b/vendor/bytelyst/palace/src/__tests__/decay.test.ts deleted file mode 100644 index dff95bd..0000000 --- a/vendor/bytelyst/palace/src/__tests__/decay.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { computeDecayedRelevance, boostRelevance } from '../decay.js'; - -describe('computeDecayedRelevance', () => { - it('returns original relevance for just-created memory', () => { - const now = new Date(); - expect(computeDecayedRelevance(0.8, now.toISOString(), 30, now)).toBeCloseTo(0.8); - }); - - it('halves relevance after exactly one half-life', () => { - const now = new Date(); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 86_400_000); - expect(computeDecayedRelevance(1.0, thirtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.5, 2); - }); - - it('quarters relevance after two half-lives', () => { - const now = new Date(); - const sixtyDaysAgo = new Date(now.getTime() - 60 * 86_400_000); - expect(computeDecayedRelevance(1.0, sixtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.25, 2); - }); - - it('returns 0 for zero relevance', () => { - expect(computeDecayedRelevance(0, '2024-01-01')).toBe(0); - }); - - it('clamps to [0, 1]', () => { - const now = new Date(); - expect(computeDecayedRelevance(1.5, now.toISOString(), 30, now)).toBeLessThanOrEqual(1); - }); - - it('returns original relevance for zero half-life', () => { - expect(computeDecayedRelevance(0.8, '2024-01-01', 0)).toBe(0.8); - }); - - it('accepts Date objects', () => { - const now = new Date(); - expect(computeDecayedRelevance(0.8, now, 30, now)).toBeCloseTo(0.8); - }); -}); - -describe('boostRelevance', () => { - it('boosts relevance toward 1.0', () => { - expect(boostRelevance(0.5, 0.3)).toBeCloseTo(0.65); - }); - - it('does not exceed 1.0', () => { - expect(boostRelevance(0.99, 0.5)).toBeLessThanOrEqual(1.0); - }); - - it('returns 0 for zero relevance with zero boost', () => { - expect(boostRelevance(0, 0)).toBe(0); - }); - - it('applies default boost factor of 0.3', () => { - expect(boostRelevance(0.5)).toBeCloseTo(0.65); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/dedup.test.ts b/vendor/bytelyst/palace/src/__tests__/dedup.test.ts deleted file mode 100644 index 680cc32..0000000 --- a/vendor/bytelyst/palace/src/__tests__/dedup.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isContentDuplicate, isExactDuplicate, findClosestMatch } from '../dedup.js'; - -describe('isContentDuplicate', () => { - const baseEmbedding = [1, 0, 0]; - const similarEmbedding = [0.99, 0.1, 0]; // very similar to base - const differentEmbedding = [0, 1, 0]; // orthogonal - - it('detects near-duplicate above threshold', () => { - expect(isContentDuplicate(baseEmbedding, [similarEmbedding], 0.9)).toBe(true); - }); - - it('rejects non-duplicate below threshold', () => { - expect(isContentDuplicate(baseEmbedding, [differentEmbedding], 0.9)).toBe(false); - }); - - it('returns false for empty existing embeddings', () => { - expect(isContentDuplicate(baseEmbedding, [], 0.9)).toBe(false); - }); - - it('skips embeddings with mismatched dimensions', () => { - expect(isContentDuplicate([1, 0], [[1, 0, 0]], 0.9)).toBe(false); - }); - - it('uses default threshold of 0.90', () => { - expect(isContentDuplicate(baseEmbedding, [similarEmbedding])).toBe(true); - }); -}); - -describe('isExactDuplicate', () => { - it('detects exact match', () => { - expect(isExactDuplicate('Hello World', 'Hello World')).toBe(true); - }); - - it('is case-insensitive', () => { - expect(isExactDuplicate('Hello', 'hello')).toBe(true); - }); - - it('trims whitespace', () => { - expect(isExactDuplicate(' hello ', 'hello')).toBe(true); - }); - - it('rejects different content', () => { - expect(isExactDuplicate('Hello', 'World')).toBe(false); - }); -}); - -describe('findClosestMatch', () => { - it('finds the closest embedding', () => { - const result = findClosestMatch( - [1, 0, 0], - [ - [0, 1, 0], - [0.9, 0.1, 0], - [0, 0, 1], - ] - ); - expect(result).not.toBeNull(); - expect(result!.index).toBe(1); - expect(result!.score).toBeGreaterThan(0.9); - }); - - it('returns null for empty embeddings', () => { - expect(findClosestMatch([1, 0], [])).toBeNull(); - }); - - it('returns null when no embedding exceeds minScore', () => { - expect(findClosestMatch([1, 0, 0], [[0, 1, 0]], 0.99)).toBeNull(); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/extraction.test.ts b/vendor/bytelyst/palace/src/__tests__/extraction.test.ts deleted file mode 100644 index a787dc7..0000000 --- a/vendor/bytelyst/palace/src/__tests__/extraction.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildExtractionPrompt, - parseExtractionResponse, - regexFallbackExtraction, -} from '../extraction.js'; - -describe('buildExtractionPrompt', () => { - it('includes hall types in the prompt', () => { - const prompt = buildExtractionPrompt('some content', { - hallTypes: ['decisions', 'events', 'discoveries'], - }); - expect(prompt).toContain('decisions, events, discoveries'); - expect(prompt).toContain('some content'); - }); - - it('includes title when provided', () => { - const prompt = buildExtractionPrompt('content', { - title: 'My Note', - hallTypes: ['decisions'], - }); - expect(prompt).toContain('Title: My Note'); - }); - - it('includes context when provided', () => { - const prompt = buildExtractionPrompt('content', { - context: 'Work brain', - hallTypes: ['decisions'], - }); - expect(prompt).toContain('Context: Work brain'); - }); - - it('omits title/context when not provided', () => { - const prompt = buildExtractionPrompt('content', { - hallTypes: ['decisions'], - }); - expect(prompt).not.toContain('Title:'); - expect(prompt).not.toContain('Context:'); - }); -}); - -describe('parseExtractionResponse', () => { - it('parses valid JSON array', () => { - const input = JSON.stringify([ - { hall: 'decisions', content: 'Use TypeScript', roomSlug: 'tech', entities: ['TypeScript'] }, - ]); - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - expect(result[0].content).toBe('Use TypeScript'); - expect(result[0].roomSlug).toBe('tech'); - expect(result[0].entities).toEqual(['TypeScript']); - }); - - it('handles JSON wrapped in code fences', () => { - const input = - '```json\n[{"hall":"events","content":"Released v2","roomSlug":"releases","entities":[]}]\n```'; - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('events'); - }); - - it('returns empty array for malformed JSON', () => { - expect(parseExtractionResponse('not json at all')).toEqual([]); - }); - - it('returns empty array for empty string', () => { - expect(parseExtractionResponse('')).toEqual([]); - }); - - it('filters out items missing required fields', () => { - const input = JSON.stringify([{ hall: 'decisions', content: 'valid' }, { noHall: true }]); - const result = parseExtractionResponse(input); - expect(result).toHaveLength(1); - }); - - it('defaults roomSlug to general', () => { - const input = JSON.stringify([{ hall: 'events', content: 'something' }]); - const result = parseExtractionResponse(input); - expect(result[0].roomSlug).toBe('general'); - }); - - it('handles room_slug snake_case variant', () => { - const input = JSON.stringify([{ hall: 'events', content: 'x', room_slug: 'my-room' }]); - const result = parseExtractionResponse(input); - expect(result[0].roomSlug).toBe('my-room'); - }); -}); - -describe('regexFallbackExtraction', () => { - it('extracts Decision: lines', () => { - const result = regexFallbackExtraction('Decision: Use Cosmos DB for storage'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - expect(result[0].content).toContain('Use Cosmos DB'); - }); - - it('extracts TODO: lines', () => { - const result = regexFallbackExtraction('TODO: Fix the auth bug'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('decisions'); - }); - - it('extracts Found: lines as discoveries', () => { - const result = regexFallbackExtraction('Found: New API endpoint for search'); - expect(result).toHaveLength(1); - expect(result[0].hall).toBe('discoveries'); - }); - - it('extracts @mentions as entities', () => { - const result = regexFallbackExtraction('Decision: @alice proposed the new schema'); - expect(result[0].entities).toContain('alice'); - }); - - it('extracts #tags as entities', () => { - const result = regexFallbackExtraction('Found: #typescript has better types'); - expect(result[0].entities).toContain('typescript'); - }); - - it('returns empty array for plain text without patterns', () => { - expect(regexFallbackExtraction('Just some random text here')).toEqual([]); - }); - - it('handles multiple patterns in multi-line content', () => { - const content = `Decision: Adopt ESM -Found: Vitest is faster -TODO: Migrate tests`; - const result = regexFallbackExtraction(content); - expect(result).toHaveLength(3); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/halls.test.ts b/vendor/bytelyst/palace/src/__tests__/halls.test.ts deleted file mode 100644 index dd07b0c..0000000 --- a/vendor/bytelyst/palace/src/__tests__/halls.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { HALL_PRESETS, getHallPreset, hallFromLabel, ALL_HALL_TYPES } from '../halls.js'; - -describe('HALL_PRESETS', () => { - it('notelett has 6 halls', () => { - expect(HALL_PRESETS.notelett.halls).toHaveLength(6); - expect(HALL_PRESETS.notelett.halls).toContain('insights'); - expect(HALL_PRESETS.notelett.halls).not.toContain('errors'); - }); - - it('mindlyst has 6 halls with patterns and emotions', () => { - expect(HALL_PRESETS.mindlyst.halls).toHaveLength(6); - expect(HALL_PRESETS.mindlyst.halls).toContain('patterns'); - expect(HALL_PRESETS.mindlyst.halls).toContain('emotions'); - }); - - it('coding has 6 halls with errors and advice', () => { - expect(HALL_PRESETS.coding.halls).toHaveLength(6); - expect(HALL_PRESETS.coding.halls).toContain('errors'); - expect(HALL_PRESETS.coding.halls).toContain('advice'); - }); -}); - -describe('getHallPreset', () => { - it('returns preset by name', () => { - expect(getHallPreset('notelett')?.name).toBe('NoteLett'); - }); - - it('returns undefined for unknown preset', () => { - expect(getHallPreset('nonexistent')).toBeUndefined(); - }); -}); - -describe('hallFromLabel', () => { - it('matches exact hall type', () => { - expect(hallFromLabel('decisions')).toBe('decisions'); - }); - - it('is case-insensitive', () => { - expect(hallFromLabel('DECISIONS')).toBe('decisions'); - }); - - it('matches singular form via synonym', () => { - expect(hallFromLabel('decision')).toBe('decisions'); - }); - - it('maps synonyms to halls', () => { - expect(hallFromLabel('bug')).toBe('errors'); - expect(hallFromLabel('feeling')).toBe('emotions'); - expect(hallFromLabel('tip')).toBe('advice'); - expect(hallFromLabel('trend')).toBe('patterns'); - expect(hallFromLabel('fact')).toBe('discoveries'); - }); - - it('respects allowedHalls filter', () => { - // 'errors' is not in notelett preset - expect(hallFromLabel('bug', HALL_PRESETS.notelett.halls)).toBeUndefined(); - // 'errors' is in coding preset - expect(hallFromLabel('bug', HALL_PRESETS.coding.halls)).toBe('errors'); - }); - - it('returns undefined for unrecognized label', () => { - expect(hallFromLabel('xyzzy')).toBeUndefined(); - }); - - it('trims whitespace', () => { - expect(hallFromLabel(' events ')).toBe('events'); - }); -}); - -describe('ALL_HALL_TYPES', () => { - it('has 9 total hall types', () => { - expect(ALL_HALL_TYPES).toHaveLength(9); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/kg.test.ts b/vendor/bytelyst/palace/src/__tests__/kg.test.ts deleted file mode 100644 index 40590f1..0000000 --- a/vendor/bytelyst/palace/src/__tests__/kg.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { findContradictions, mergeTriples, isTripleCurrent } from '../kg.js'; -import type { TripleInput } from '../kg.js'; - -const now = new Date('2025-06-01T00:00:00Z'); - -describe('isTripleCurrent', () => { - it('returns true for triple with no validTo', () => { - expect(isTripleCurrent({ validTo: undefined }, now)).toBe(true); - }); - - it('returns true for triple with future validTo', () => { - expect(isTripleCurrent({ validTo: '2026-01-01T00:00:00Z' }, now)).toBe(true); - }); - - it('returns false for triple with past validTo', () => { - expect(isTripleCurrent({ validTo: '2024-01-01T00:00:00Z' }, now)).toBe(false); - }); -}); - -describe('findContradictions', () => { - it('detects contradiction (same subject+predicate, different object)', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - const result = findContradictions(existing, incoming, now); - expect(result).toHaveLength(1); - expect(result[0].existing.object).toBe('PostgreSQL'); - expect(result[0].incoming.object).toBe('Cosmos DB'); - }); - - it('does not flag same triple as contradiction', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(0); - }); - - it('ignores expired triples', () => { - const existing: TripleInput[] = [ - { - subject: 'App', - predicate: 'uses', - object: 'PostgreSQL', - validFrom: '2024-01-01', - validTo: '2024-12-31', - }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(0); - }); - - it('is case-insensitive', () => { - const existing: TripleInput[] = [ - { subject: 'app', predicate: 'Uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - expect(findContradictions(existing, incoming, now)).toHaveLength(1); - }); -}); - -describe('mergeTriples', () => { - it('adds non-conflicting triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'runs-on', object: 'Node.js', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.added).toHaveLength(1); - expect(result.skipped).toHaveLength(0); - expect(result.invalidated).toHaveLength(0); - expect(result.merged).toHaveLength(2); - }); - - it('skips duplicate triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.skipped).toHaveLength(1); - expect(result.added).toHaveLength(0); - }); - - it('invalidates contradicted triples', () => { - const existing: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, - ]; - const incoming: TripleInput[] = [ - { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, - ]; - const result = mergeTriples(existing, incoming, now); - expect(result.invalidated).toHaveLength(1); - expect(result.added).toHaveLength(1); - // The invalidated triple should have validTo set - const invalidated = result.merged.find(t => t.object === 'PostgreSQL'); - expect(invalidated?.validTo).toBeDefined(); - }); -}); diff --git a/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts b/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts deleted file mode 100644 index a4e58b4..0000000 --- a/vendor/bytelyst/palace/src/__tests__/wakeup.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - buildWakeUpLayers, - truncateToTokenBudget, - estimateTokens, - WAKEUP_PRESETS, -} from '../wakeup.js'; - -describe('estimateTokens', () => { - it('estimates ~4 chars per token', () => { - expect(estimateTokens('abcd')).toBe(1); - expect(estimateTokens('abcdefgh')).toBe(2); - }); - - it('returns 0 for empty string', () => { - expect(estimateTokens('')).toBe(0); - }); -}); - -describe('truncateToTokenBudget', () => { - it('returns unchanged text if within budget', () => { - expect(truncateToTokenBudget('short', 100)).toBe('short'); - }); - - it('truncates long text with ellipsis', () => { - const longText = 'a'.repeat(1000); - const result = truncateToTokenBudget(longText, 10); // 40 chars max - expect(result.length).toBeLessThanOrEqual(43); // 40 + '...' - expect(result.endsWith('...')).toBe(true); - }); - - it('returns empty string for empty input', () => { - expect(truncateToTokenBudget('', 100)).toBe(''); - }); -}); - -describe('buildWakeUpLayers', () => { - const config = WAKEUP_PRESETS.notelett; // 600 total, 50/150/400 - - it('assembles all three layers', () => { - const result = buildWakeUpLayers( - 'Project: NoteLett', - 'Key fact: Uses Cosmos DB', - 'Related: User asked about search', - config - ); - expect(result.text).toContain('[Identity]'); - expect(result.text).toContain('[Critical Facts]'); - expect(result.text).toContain('[Relevant Memories]'); - expect(result.layers).toHaveLength(3); - }); - - it('handles empty L0', () => { - const result = buildWakeUpLayers('', 'facts', 'memories', config); - expect(result.layers.find(l => l.label === 'L0:identity')).toBeUndefined(); - expect(result.layers).toHaveLength(2); - }); - - it('handles all empty layers gracefully', () => { - const result = buildWakeUpLayers('', '', '', config); - expect(result.text).toBe(''); - expect(result.layers).toHaveLength(0); - expect(result.truncated).toBe(false); - }); - - it('marks truncated when content exceeds budget', () => { - const longL2 = 'x '.repeat(5000); - const result = buildWakeUpLayers('id', 'fact', longL2, config); - expect(result.truncated).toBe(true); - }); - - it('respects total budget', () => { - const result = buildWakeUpLayers( - 'identity context here', - 'critical facts here', - 'semantic memories here', - config - ); - const tokens = estimateTokens(result.text); - expect(tokens).toBeLessThanOrEqual(config.totalBudget + 10); // small margin for headers - }); -}); - -describe('WAKEUP_PRESETS', () => { - it('has notelett preset with 600 budget', () => { - expect(WAKEUP_PRESETS.notelett.totalBudget).toBe(600); - }); - - it('has mindlyst preset with 800 budget', () => { - expect(WAKEUP_PRESETS.mindlyst.totalBudget).toBe(800); - }); - - it('has coding preset with 800 budget', () => { - expect(WAKEUP_PRESETS.coding.totalBudget).toBe(800); - }); -}); diff --git a/vendor/bytelyst/palace/src/config.ts b/vendor/bytelyst/palace/src/config.ts deleted file mode 100644 index 569c9b5..0000000 --- a/vendor/bytelyst/palace/src/config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Palace configuration schema — Zod-based env var validation. - * - * Products extend their backend config with these palace-specific vars. - */ - -import { z } from 'zod'; - -export const palaceConfigSchema = z.object({ - PALACE_ENABLED: z - .enum(['true', 'false']) - .default('true') - .transform(v => v === 'true'), - - PALACE_WAKE_UP_BUDGET: z.coerce.number().default(800), - - PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.9), - - PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().min(1).default(30), - - PALACE_MAX_MEMORIES_PER_SEARCH: z.coerce.number().min(1).default(20), - - PALACE_EXTRACTION_MAX_CHARS: z.coerce.number().min(100).default(6000), -}); - -export type PalaceConfig = z.infer; - -/** - * Parse palace config from environment. - * Products typically merge this with their own config schema. - */ -export function parsePalaceConfig( - env: Record = process.env as Record -): PalaceConfig { - return palaceConfigSchema.parse(env); -} diff --git a/vendor/bytelyst/palace/src/cosine.ts b/vendor/bytelyst/palace/src/cosine.ts deleted file mode 100644 index c6b5ae2..0000000 --- a/vendor/bytelyst/palace/src/cosine.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Vector similarity utilities for semantic search and deduplication. - */ - -/** - * Compute cosine similarity between two vectors. - * Returns a value between -1 and 1 (1 = identical direction). - * Returns 0 if either vector is zero-length or dimensions don't match. - */ -export function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0; - - let dotProduct = 0; - let normA = 0; - let normB = 0; - - for (let i = 0; i < a.length; i++) { - dotProduct += a[i] * b[i]; - normA += a[i] * a[i]; - normB += b[i] * b[i]; - } - - const denominator = Math.sqrt(normA) * Math.sqrt(normB); - if (denominator === 0) return 0; - - return dotProduct / denominator; -} - -/** - * Normalize a vector to unit length (magnitude = 1). - * Returns a zero vector if input is zero-length. - */ -export function normalizeVector(v: number[]): number[] { - const magnitude = Math.sqrt(v.reduce((sum, val) => sum + val * val, 0)); - if (magnitude === 0) return v.map(() => 0); - return v.map(val => val / magnitude); -} - -/** - * Find the top-K most similar items to a query vector. - * - * @param query - The query embedding vector - * @param items - Array of items to search - * @param getEmbedding - Function to extract embedding from an item (returns undefined if missing) - * @param k - Maximum number of results to return - * @param minScore - Minimum cosine similarity score (default: 0) - * @returns Sorted array of { item, score } pairs, highest score first - */ -export function topKByCosine( - query: number[], - items: T[], - getEmbedding: (item: T) => number[] | undefined, - k: number, - minScore = 0 -): Array<{ item: T; score: number }> { - const scored: Array<{ item: T; score: number }> = []; - - for (const item of items) { - const embedding = getEmbedding(item); - if (!embedding || embedding.length === 0) continue; - - const score = cosineSimilarity(query, embedding); - if (score >= minScore) { - scored.push({ item, score }); - } - } - - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, k); -} diff --git a/vendor/bytelyst/palace/src/decay.ts b/vendor/bytelyst/palace/src/decay.ts deleted file mode 100644 index 122e85f..0000000 --- a/vendor/bytelyst/palace/src/decay.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Relevance decay for palace memories. - * - * Uses exponential half-life decay: relevance halves every N days. - * Memories that are accessed/referenced get their relevance boosted. - */ - -const MS_PER_DAY = 86_400_000; - -/** - * Compute decayed relevance using exponential half-life. - * - * @param originalRelevance - Initial relevance score (0-1) - * @param createdAt - ISO date string or Date when the memory was created/last accessed - * @param halfLifeDays - Number of days for relevance to halve (default: 30) - * @param asOf - Reference time for decay calculation (default: now) - * @returns Decayed relevance score (0-1) - */ -export function computeDecayedRelevance( - originalRelevance: number, - createdAt: string | Date, - halfLifeDays = 30, - asOf: Date = new Date() -): number { - if (halfLifeDays <= 0) return originalRelevance; - if (originalRelevance <= 0) return 0; - - const created = typeof createdAt === 'string' ? new Date(createdAt) : createdAt; - const elapsedMs = asOf.getTime() - created.getTime(); - - if (elapsedMs <= 0) return Math.min(originalRelevance, 1); - - const elapsedDays = elapsedMs / MS_PER_DAY; - const decayFactor = Math.pow(0.5, elapsedDays / halfLifeDays); - - return Math.max(0, Math.min(1, originalRelevance * decayFactor)); -} - -/** - * Compute a boosted relevance for a memory that was accessed/referenced. - * - * Boost formula: new = old + (1 - old) * boostFactor - * This asymptotically approaches 1.0 without exceeding it. - * - * @param currentRelevance - Current relevance score (0-1) - * @param boostFactor - How much of the remaining gap to close (default: 0.3) - * @returns Boosted relevance score (0-1) - */ -export function boostRelevance(currentRelevance: number, boostFactor = 0.3): number { - const boosted = currentRelevance + (1 - currentRelevance) * boostFactor; - return Math.min(1, Math.max(0, boosted)); -} diff --git a/vendor/bytelyst/palace/src/dedup.ts b/vendor/bytelyst/palace/src/dedup.ts deleted file mode 100644 index 9fe604e..0000000 --- a/vendor/bytelyst/palace/src/dedup.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Deduplication utilities for palace memories. - * - * Detects near-duplicate content using cosine similarity over embeddings. - * Products handle the Cosmos/DB queries; this module operates on pure data. - */ - -import { cosineSimilarity } from './cosine.js'; - -/** - * Check if a candidate embedding is a near-duplicate of any existing embedding. - * - * @param candidate - Embedding of the new memory - * @param existingEmbeddings - Embeddings of existing memories in the same room/hall - * @param threshold - Cosine similarity threshold (default: 0.90) - * @returns true if any existing embedding exceeds the threshold - */ -export function isContentDuplicate( - candidate: number[], - existingEmbeddings: number[][], - threshold = 0.9 -): boolean { - for (const existing of existingEmbeddings) { - if (existing.length !== candidate.length) continue; - if (cosineSimilarity(candidate, existing) > threshold) { - return true; - } - } - return false; -} - -/** - * Check if two text strings are exact duplicates after normalization. - * Trims whitespace and lowercases before comparison. - */ -export function isExactDuplicate(a: string, b: string): boolean { - return a.trim().toLowerCase() === b.trim().toLowerCase(); -} - -/** - * Find the most similar embedding and return its index + score. - * Returns null if no embeddings exist or none exceed minScore. - */ -export function findClosestMatch( - candidate: number[], - existingEmbeddings: number[][], - minScore = 0 -): { index: number; score: number } | null { - let bestIndex = -1; - let bestScore = minScore; - - for (let i = 0; i < existingEmbeddings.length; i++) { - const existing = existingEmbeddings[i]; - if (existing.length !== candidate.length) continue; - - const score = cosineSimilarity(candidate, existing); - if (score > bestScore) { - bestScore = score; - bestIndex = i; - } - } - - return bestIndex >= 0 ? { index: bestIndex, score: bestScore } : null; -} diff --git a/vendor/bytelyst/palace/src/extraction.ts b/vendor/bytelyst/palace/src/extraction.ts deleted file mode 100644 index bd3c304..0000000 --- a/vendor/bytelyst/palace/src/extraction.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Memory extraction utilities — prompt building, response parsing, regex fallback. - * - * Products call their own LLM provider with the prompt from buildExtractionPrompt(), - * then pass the response to parseExtractionResponse(). - * If LLM is unavailable, regexFallbackExtraction() provides basic extraction. - */ - -import type { ExtractedMemory } from './types.js'; - -export interface ExtractionContext { - title?: string; - context?: string; - hallTypes: readonly string[]; -} - -/** - * Build a structured extraction prompt for an LLM. - * - * @param content - The text content to extract memories from - * @param ctx - Context including title, additional context, and allowed hall types - * @returns A system/user prompt string ready for LLM chat() - */ -export function buildExtractionPrompt(content: string, ctx: ExtractionContext): string { - const hallList = ctx.hallTypes.join(', '); - const titleLine = ctx.title ? `\nTitle: ${ctx.title}` : ''; - const contextLine = ctx.context ? `\nContext: ${ctx.context}` : ''; - - return `Extract structured memories from the following content. - -For each distinct memory, return a JSON array where each element has: -- "hall": one of [${hallList}] -- "content": the memory summarized in 1-2 sentences -- "roomSlug": a short kebab-case topic slug (e.g. "auth-migration", "api-design") -- "entities": array of named entities mentioned (people, projects, technologies, places) - -Rules: -- Only extract genuinely important or referenceable facts, decisions, or events -- Skip trivial or obvious statements -- Each memory should be self-contained (understandable without the original context) -- Prefer specific details over vague summaries -- Return valid JSON only — no markdown fences, no explanation${titleLine}${contextLine} - -Content: -${content}`; -} - -/** - * Parse an LLM extraction response into ExtractedMemory[]. - * - * Handles: - * - Clean JSON arrays - * - JSON wrapped in markdown code fences - * - Malformed JSON (returns empty array) - */ -export function parseExtractionResponse(llmOutput: string): ExtractedMemory[] { - if (!llmOutput || llmOutput.trim().length === 0) return []; - - let cleaned = llmOutput.trim(); - - // Strip markdown code fences if present - if (cleaned.startsWith('```')) { - cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); - } - - try { - const parsed = JSON.parse(cleaned); - - if (!Array.isArray(parsed)) return []; - - return parsed - .filter( - (item: unknown): item is Record => - typeof item === 'object' && item !== null && 'hall' in item && 'content' in item - ) - .map(item => ({ - hall: String(item.hall || ''), - content: String(item.content || ''), - roomSlug: String(item.roomSlug || item.room_slug || 'general'), - entities: Array.isArray(item.entities) ? item.entities.map(String) : [], - })); - } catch { - return []; - } -} - -/** - * Regex-based fallback extraction when LLM is unavailable. - * - * Scans for common patterns: - * - "Decision:" / "Decided:" → decisions - * - "TODO:" / "Action:" → decisions - * - "Found:" / "Discovered:" / "Learned:" → discoveries - * - "Prefer:" / "Always:" / "Never:" → preferences - * - "Event:" / "Happened:" / date patterns → events - * - "Tip:" / "Note:" / "Remember:" → advice - * - * @param content - Raw text content - * @returns Array of extracted memories (best-effort) - */ -export function regexFallbackExtraction(content: string): ExtractedMemory[] { - const memories: ExtractedMemory[] = []; - const lines = content.split('\n'); - - const patterns: Array<{ regex: RegExp; hall: string }> = [ - { regex: /^(?:decision|decided|resolve[ds]?):\s*(.+)/i, hall: 'decisions' }, - { regex: /^(?:todo|action|task):\s*(.+)/i, hall: 'decisions' }, - { regex: /^(?:found|discovered|learned|til):\s*(.+)/i, hall: 'discoveries' }, - { regex: /^(?:prefer|always|never):\s*(.+)/i, hall: 'preferences' }, - { regex: /^(?:event|happened|occurred):\s*(.+)/i, hall: 'events' }, - { regex: /^(?:tip|note|remember|important):\s*(.+)/i, hall: 'advice' }, - { regex: /^(?:error|bug|issue|broken):\s*(.+)/i, hall: 'errors' }, - { regex: /^(?:pattern|recurring|trend):\s*(.+)/i, hall: 'patterns' }, - { regex: /^(?:feeling|mood|emotion):\s*(.+)/i, hall: 'emotions' }, - { regex: /^(?:insight|observation|noticed):\s*(.+)/i, hall: 'insights' }, - ]; - - for (const line of lines) { - const trimmed = line.replace(/^[\s\-*>#]+/, '').trim(); - if (!trimmed) continue; - - for (const { regex, hall } of patterns) { - const match = trimmed.match(regex); - if (match && match[1]) { - memories.push({ - hall, - content: match[1].trim(), - roomSlug: 'general', - entities: extractEntities(match[1]), - }); - break; - } - } - } - - return memories; -} - -/** - * Extract simple entities from text (mentions, tags, capitalized phrases). - */ -function extractEntities(text: string): string[] { - const entities = new Set(); - - // @mentions - const mentions = text.match(/@(\w+)/g); - if (mentions) mentions.forEach(m => entities.add(m.slice(1))); - - // #tags - const tags = text.match(/#(\w+)/g); - if (tags) tags.forEach(t => entities.add(t.slice(1))); - - return Array.from(entities); -} diff --git a/vendor/bytelyst/palace/src/halls.ts b/vendor/bytelyst/palace/src/halls.ts deleted file mode 100644 index 3e4220f..0000000 --- a/vendor/bytelyst/palace/src/halls.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Hall types and presets for different products. - * - * Each product picks a preset (or defines custom halls). - * Halls categorize memories by type — decisions, events, discoveries, etc. - */ - -export const ALL_HALL_TYPES = [ - 'decisions', - 'events', - 'discoveries', - 'preferences', - 'advice', - 'insights', - 'patterns', - 'emotions', - 'errors', -] as const; - -export type HallType = (typeof ALL_HALL_TYPES)[number]; - -export interface HallPreset { - name: string; - halls: HallType[]; -} - -/** - * Product-specific hall presets. - * - * - notelett: insights instead of errors (note-taking domain) - * - mindlyst: patterns + emotions (multimodal/emotional domain) - * - coding: errors + advice (developer/agent domain, e.g. Claw-Cowork) - */ -export const HALL_PRESETS: Record = { - notelett: { - name: 'NoteLett', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'insights'], - }, - mindlyst: { - name: 'MindLyst', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'patterns', 'emotions'], - }, - coding: { - name: 'Coding Agent', - halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'errors'], - }, -}; - -/** - * Get a hall preset by name. - * Returns undefined if the preset does not exist. - */ -export function getHallPreset(presetName: string): HallPreset | undefined { - return HALL_PRESETS[presetName]; -} - -/** - * Classify a label string to the closest hall type. - * Case-insensitive, tries exact match first, then substring. - * Returns undefined if no match. - */ -export function hallFromLabel(label: string, allowedHalls?: HallType[]): HallType | undefined { - const normalized = label.toLowerCase().trim(); - const candidates = allowedHalls ?? (ALL_HALL_TYPES as unknown as HallType[]); - - // Exact match - const exact = candidates.find(h => h === normalized); - if (exact) return exact; - - // Substring match (e.g. "decision" → "decisions") - const partial = candidates.find(h => h.startsWith(normalized) || normalized.startsWith(h)); - if (partial) return partial; - - // Common synonyms - const synonymMap: Record = { - decision: 'decisions', - event: 'events', - discovery: 'discoveries', - preference: 'preferences', - insight: 'insights', - pattern: 'patterns', - emotion: 'emotions', - error: 'errors', - fact: 'discoveries', - finding: 'discoveries', - todo: 'decisions', - task: 'decisions', - bug: 'errors', - fix: 'decisions', - feeling: 'emotions', - mood: 'emotions', - trend: 'patterns', - recurring: 'patterns', - tip: 'advice', - recommendation: 'advice', - suggestion: 'advice', - }; - - const synonym = synonymMap[normalized]; - if (synonym && candidates.includes(synonym)) return synonym; - - return undefined; -} diff --git a/vendor/bytelyst/palace/src/index.ts b/vendor/bytelyst/palace/src/index.ts deleted file mode 100644 index 4689d58..0000000 --- a/vendor/bytelyst/palace/src/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -// ── Types ────────────────────────────────────────────────────────── -export type { - BasePalaceWingDoc, - BasePalaceRoomDoc, - BasePalaceMemoryDoc, - BasePalaceTunnelDoc, - BasePalaceKGTripleDoc, - BasePalaceDiaryDoc, - ExtractedMemory, -} from './types.js'; - -// ── Halls ────────────────────────────────────────────────────────── -export { ALL_HALL_TYPES, HALL_PRESETS, getHallPreset, hallFromLabel } from './halls.js'; -export type { HallType, HallPreset } from './halls.js'; - -// ── Cosine Similarity ────────────────────────────────────────────── -export { cosineSimilarity, normalizeVector, topKByCosine } from './cosine.js'; - -// ── Deduplication ────────────────────────────────────────────────── -export { isContentDuplicate, isExactDuplicate, findClosestMatch } from './dedup.js'; - -// ── Relevance Decay ──────────────────────────────────────────────── -export { computeDecayedRelevance, boostRelevance } from './decay.js'; - -// ── Extraction ───────────────────────────────────────────────────── -export { - buildExtractionPrompt, - parseExtractionResponse, - regexFallbackExtraction, -} from './extraction.js'; -export type { ExtractionContext } from './extraction.js'; - -// ── Knowledge Graph ──────────────────────────────────────────────── -export { findContradictions, mergeTriples, isTripleCurrent } from './kg.js'; -export type { TripleInput } from './kg.js'; - -// ── Wake-Up Context ──────────────────────────────────────────────── -export { - buildWakeUpLayers, - truncateToTokenBudget, - estimateTokens, - WAKEUP_PRESETS, -} from './wakeup.js'; -export type { WakeUpConfig, WakeUpContext, WakeUpLayer } from './wakeup.js'; - -// ── Config ───────────────────────────────────────────────────────── -export { palaceConfigSchema, parsePalaceConfig } from './config.js'; -export type { PalaceConfig } from './config.js'; diff --git a/vendor/bytelyst/palace/src/kg.ts b/vendor/bytelyst/palace/src/kg.ts deleted file mode 100644 index def0052..0000000 --- a/vendor/bytelyst/palace/src/kg.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Knowledge graph helpers for palace triple management. - * - * Triples are (subject, predicate, object) with temporal validity. - * This module provides pure functions for contradiction detection, - * merging, and currency checking. - */ - -/** - * A lightweight triple for comparison (does not require full doc fields). - */ -export interface TripleInput { - subject: string; - predicate: string; - object: string; - validFrom: string; - validTo?: string; - confidence?: number; -} - -/** - * Find contradictions between existing triples and incoming ones. - * - * A contradiction exists when: - * - Same subject + predicate, different object - * - Both are currently valid (no validTo or validTo in the future) - * - * @returns Array of { existing, incoming } contradiction pairs - */ -export function findContradictions( - existing: TripleInput[], - incoming: TripleInput[], - asOf: Date = new Date() -): Array<{ existing: TripleInput; incoming: TripleInput }> { - const contradictions: Array<{ existing: TripleInput; incoming: TripleInput }> = []; - - for (const inc of incoming) { - for (const ext of existing) { - if ( - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) !== normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) && - isTripleCurrent(inc, asOf) - ) { - contradictions.push({ existing: ext, incoming: inc }); - } - } - } - - return contradictions; -} - -/** - * Merge incoming triples into existing set. - * - * - If an incoming triple contradicts an existing one, the existing triple - * is invalidated (validTo set) and the incoming one is kept. - * - If an incoming triple is a duplicate (same S/P/O), it is skipped. - * - Otherwise, the incoming triple is added. - * - * @returns { merged, invalidated, added, skipped } counts - */ -export function mergeTriples( - existing: TripleInput[], - incoming: TripleInput[], - asOf: Date = new Date() -): { - merged: TripleInput[]; - invalidated: TripleInput[]; - added: TripleInput[]; - skipped: TripleInput[]; -} { - const invalidated: TripleInput[] = []; - const added: TripleInput[] = []; - const skipped: TripleInput[] = []; - - const merged = [...existing]; - - for (const inc of incoming) { - // Check for exact duplicate - const isDuplicate = merged.some( - ext => - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) === normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) - ); - - if (isDuplicate) { - skipped.push(inc); - continue; - } - - // Check for contradiction - const contradictIdx = merged.findIndex( - ext => - normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && - normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && - normalizeEntity(ext.object) !== normalizeEntity(inc.object) && - isTripleCurrent(ext, asOf) - ); - - if (contradictIdx >= 0) { - // Invalidate the old triple - const old = merged[contradictIdx]; - merged[contradictIdx] = { ...old, validTo: asOf.toISOString() }; - invalidated.push(old); - } - - merged.push(inc); - added.push(inc); - } - - return { merged, invalidated, added, skipped }; -} - -/** - * Check if a triple is currently valid. - * - * @param triple - The triple to check - * @param asOf - Reference time (default: now) - * @returns true if the triple has no validTo or validTo is in the future - */ -export function isTripleCurrent( - triple: Pick, - asOf: Date = new Date() -): boolean { - if (!triple.validTo) return true; - return new Date(triple.validTo).getTime() > asOf.getTime(); -} - -/** - * Normalize an entity string for comparison (lowercase, trim, collapse whitespace). - */ -function normalizeEntity(s: string): string { - return s.toLowerCase().trim().replace(/\s+/g, ' '); -} diff --git a/vendor/bytelyst/palace/src/types.ts b/vendor/bytelyst/palace/src/types.ts deleted file mode 100644 index d62f84b..0000000 --- a/vendor/bytelyst/palace/src/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Base palace document types. - * - * Products extend these with product-specific fields - * (e.g. sourceWorkspaceId for NoteLett, sourceBrainId for MindLyst). - */ - -// ── Wing (top-level grouping — workspace/brain/project) ──────────── - -export interface BasePalaceWingDoc { - id: string; - productId: string; - userId: string; - name: string; - description?: string; - memoryCount: number; - l1Cache?: string; - l1CacheUpdatedAt?: string; - createdAt: string; - updatedAt: string; -} - -// ── Room (topic within a wing) ───────────────────────────────────── - -export interface BasePalaceRoomDoc { - id: string; - productId: string; - userId: string; - wingId: string; - name: string; - description?: string; - memoryCount: number; - createdAt: string; - updatedAt: string; -} - -// ── Memory (core unit — one fact/decision/event/etc.) ────────────── - -export interface BasePalaceMemoryDoc { - id: string; - productId: string; - userId: string; - wingId: string; - roomId: string; - hall: string; - content: string; - relevance: number; - embedding?: number[]; - sourceId?: string; - createdAt: string; - updatedAt: string; -} - -// ── Tunnel (cross-room/cross-wing link) ──────────────────────────── - -export interface BasePalaceTunnelDoc { - id: string; - productId: string; - userId: string; - fromMemoryId: string; - fromWingId: string; - toMemoryId: string; - toWingId: string; - relationship: string; - strength: number; - createdAt: string; -} - -// ── Knowledge Graph Triple ───────────────────────────────────────── - -export interface BasePalaceKGTripleDoc { - id: string; - productId: string; - userId: string; - wingId: string; - subject: string; - predicate: string; - object: string; - confidence: number; - validFrom: string; - validTo?: string; - sourceMemoryId?: string; - createdAt: string; -} - -// ── Diary Entry ──────────────────────────────────────────────────── - -export interface BasePalaceDiaryDoc { - id: string; - productId: string; - userId: string; - roleId: string; - wingId?: string; - entry: string; - createdAt: string; -} - -// ── Extracted memory (output of extraction pipeline) ─────────────── - -export interface ExtractedMemory { - hall: string; - content: string; - roomSlug: string; - entities: string[]; -} diff --git a/vendor/bytelyst/palace/src/wakeup.ts b/vendor/bytelyst/palace/src/wakeup.ts deleted file mode 100644 index 22717b3..0000000 --- a/vendor/bytelyst/palace/src/wakeup.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Wake-up context builder for palace-augmented sessions. - * - * Builds a layered context string (L0/L1/L2) within a token budget. - * Products provide the raw data; this module assembles and truncates. - */ - -export interface WakeUpLayer { - label: string; - content: string; - priority: number; -} - -export interface WakeUpConfig { - totalBudget: number; - l0Budget: number; - l1Budget: number; - l2Budget: number; -} - -export interface WakeUpContext { - text: string; - layers: { label: string; charCount: number }[]; - totalChars: number; - truncated: boolean; -} - -/** - * Approximate token count for a string. - * Uses the rough heuristic of ~4 characters per token. - */ -export function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -/** - * Truncate text to fit within a token budget. - * - * @param text - Input text - * @param maxTokens - Maximum tokens allowed - * @returns Truncated text (with "..." appended if truncated) - */ -export function truncateToTokenBudget(text: string, maxTokens: number): string { - if (!text) return ''; - - const maxChars = maxTokens * 4; - if (text.length <= maxChars) return text; - - // Truncate at word boundary - const truncated = text.slice(0, maxChars); - const lastSpace = truncated.lastIndexOf(' '); - const cutPoint = lastSpace > maxChars * 0.8 ? lastSpace : maxChars; - - return truncated.slice(0, cutPoint) + '...'; -} - -/** - * Build a wake-up context from L0/L1/L2 layers within a total token budget. - * - * Layer priority: - * - L0 (identity/project context) — always included, smallest budget - * - L1 (critical facts from recent memories) — high priority - * - L2 (semantically relevant memories) — fills remaining budget - * - * @param l0 - Identity/project context string - * @param l1 - Critical facts string - * @param l2 - Semantically relevant memories string - * @param config - Token budget configuration - * @returns Assembled wake-up context with metadata - */ -export function buildWakeUpLayers( - l0: string, - l1: string, - l2: string, - config: WakeUpConfig -): WakeUpContext { - const layers: { label: string; charCount: number }[] = []; - const parts: string[] = []; - let truncated = false; - - // L0: identity (always included) - const l0Truncated = truncateToTokenBudget(l0, config.l0Budget); - if (l0Truncated) { - parts.push(`[Identity]\n${l0Truncated}`); - layers.push({ label: 'L0:identity', charCount: l0Truncated.length }); - if (l0Truncated.endsWith('...')) truncated = true; - } - - // L1: critical facts - const l1Truncated = truncateToTokenBudget(l1, config.l1Budget); - if (l1Truncated) { - parts.push(`[Critical Facts]\n${l1Truncated}`); - layers.push({ label: 'L1:facts', charCount: l1Truncated.length }); - if (l1Truncated.endsWith('...')) truncated = true; - } - - // L2: semantic context (gets remaining budget) - const usedTokens = estimateTokens(parts.join('\n\n')); - const remainingBudget = Math.max(0, config.totalBudget - usedTokens); - const l2Budget = Math.min(config.l2Budget, remainingBudget); - - const l2Truncated = truncateToTokenBudget(l2, l2Budget); - if (l2Truncated) { - parts.push(`[Relevant Memories]\n${l2Truncated}`); - layers.push({ label: 'L2:semantic', charCount: l2Truncated.length }); - if (l2Truncated.endsWith('...')) truncated = true; - } - - const text = parts.join('\n\n'); - - return { - text, - layers, - totalChars: text.length, - truncated, - }; -} - -/** - * Default wake-up configs for each product. - */ -export const WAKEUP_PRESETS: Record = { - notelett: { totalBudget: 600, l0Budget: 50, l1Budget: 150, l2Budget: 400 }, - mindlyst: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, - coding: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, -}; diff --git a/vendor/bytelyst/palace/tsconfig.json b/vendor/bytelyst/palace/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/palace/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/platform-client/package.json b/vendor/bytelyst/platform-client/package.json deleted file mode 100644 index 79195c1..0000000 --- a/vendor/bytelyst/platform-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/platform-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe typed fetch wrapper for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/platform-client/src/index.test.ts b/vendor/bytelyst/platform-client/src/index.test.ts deleted file mode 100644 index e39a0cf..0000000 --- a/vendor/bytelyst/platform-client/src/index.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createPlatformClient, ApiError } from './index.js'; - -describe('createPlatformClient', () => { - const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', - }; - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should make GET requests with auth header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({ items: [] }), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - const result = await api.get('/items'); - - expect(result).toEqual({ items: [] }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/items', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer test-token', - 'x-product-id': 'testapp', - }), - }) - ); - }); - - it('should make POST requests with body', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({ id: '1' }), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - const result = await api.post('/items', { name: 'test' }); - - expect(result).toEqual({ id: '1' }); - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/items', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ name: 'test' }), - }) - ); - }); - - it('should handle 204 No Content', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - status: 204, - json: () => Promise.reject(new Error('no body')), - }) - ); - - const api = createPlatformClient(baseConfig); - const result = await api.del('/items/1'); - - expect(result).toBeUndefined(); - }); - - it('should throw ApiError on non-OK response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => Promise.resolve({ message: 'Bad request' }), - }) - ); - - const api = createPlatformClient(baseConfig); - - await expect(api.get('/items')).rejects.toThrow(ApiError); - try { - await api.get('/items'); - } catch (e) { - expect(e).toBeInstanceOf(ApiError); - expect((e as ApiError).status).toBe(400); - } - }); - - it('should attempt token refresh on 401', async () => { - let callCount = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 1) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ message: 'Unauthorized' }), - }); - } - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ refreshed: true }), - }); - }) - ); - - const refreshFn = vi.fn().mockResolvedValue(true); - const api = createPlatformClient({ - ...baseConfig, - refreshAccessToken: refreshFn, - }); - - const result = await api.get('/items'); - expect(refreshFn).toHaveBeenCalledOnce(); - expect(result).toEqual({ refreshed: true }); - }); - - it('should not include auth header when token is null', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({}), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient({ - ...baseConfig, - getAccessToken: () => null, - }); - await api.get('/public/items'); - - const headers = fetchMock.mock.calls[0][1].headers as Record; - expect(headers['Authorization']).toBeUndefined(); - }); - - it('should include x-request-id header', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve({}), - }); - vi.stubGlobal('fetch', fetchMock); - - const api = createPlatformClient(baseConfig); - await api.get('/items'); - - const headers = fetchMock.mock.calls[0][1].headers as Record; - expect(headers['x-request-id']).toBeDefined(); - expect(headers['x-request-id'].length).toBeGreaterThan(0); - }); -}); diff --git a/vendor/bytelyst/platform-client/src/index.ts b/vendor/bytelyst/platform-client/src/index.ts deleted file mode 100644 index ca87d7d..0000000 --- a/vendor/bytelyst/platform-client/src/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Browser/React Native-safe typed fetch wrapper for platform-service. - * - * Client-side counterpart to @bytelyst/api-client (which is server-side). - * Uses bearer tokens from storage (not httpOnly cookies). - * Includes auto-retry on 401 via token refresh. - * - * @example - * ```ts - * import { createPlatformClient } from '@bytelyst/platform-client'; - * - * const api = createPlatformClient({ - * baseUrl: 'http://localhost:4003/api', - * productId: 'nomgap', - * getAccessToken: () => authClient.getAccessToken(), - * refreshAccessToken: () => authClient.refreshAccessToken(), - * }); - * - * const sessions = await api.get('/fasting-sessions'); - * await api.post('/fasting-sessions', { protocol: '16:8' }); - * ``` - */ - -// ── Types ──────────────────────────────────────────────────── - -export interface PlatformClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header. */ - productId: string; - - /** Function that returns the current access token, or null. */ - getAccessToken: () => string | null; - - /** Optional function to refresh the access token. Returns true on success. */ - refreshAccessToken?: () => Promise; - - /** Request timeout in milliseconds. Default: 15000. */ - timeoutMs?: number; -} - -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - message?: string - ) { - super(message ?? `API error ${status}`); - this.name = 'ApiError'; - } -} - -export interface PlatformClient { - get(path: string, headers?: Record): Promise; - post(path: string, body?: unknown, headers?: Record): Promise; - put(path: string, body?: unknown, headers?: Record): Promise; - del(path: string, headers?: Record): Promise; - request( - method: string, - path: string, - body?: unknown, - headers?: Record - ): Promise; -} - -// ── UUID helper ────────────────────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Factory ────────────────────────────────────────────────── - -export function createPlatformClient(config: PlatformClientConfig): PlatformClient { - const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config; - - async function doRequest( - method: string, - path: string, - body?: unknown, - extraHeaders?: Record, - isRetry = false - ): Promise { - const url = `${baseUrl}${path}`; - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - ...extraHeaders, - }; - - const token = getAccessToken(); - if (token) headers['Authorization'] = `Bearer ${token}`; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await globalThis.fetch(url, { - method, - headers, - body: body != null ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - if (res.status === 204) return undefined as T; - - const json = await res.json().catch(() => ({})); - - // Auto-refresh on 401 (once) - if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) { - clearTimeout(timer); - const refreshed = await refreshAccessToken(); - if (refreshed) { - return doRequest(method, path, body, extraHeaders, true); - } - } - - if (!res.ok) { - throw new ApiError( - res.status, - json, - (json as Record).message ?? `HTTP ${res.status}` - ); - } - - return json as T; - } finally { - clearTimeout(timer); - } - } - - return { - get: (path: string, headers?: Record) => - doRequest('GET', path, undefined, headers), - post: (path: string, body?: unknown, headers?: Record) => - doRequest('POST', path, body, headers), - put: (path: string, body?: unknown, headers?: Record) => - doRequest('PUT', path, body, headers), - del: (path: string, headers?: Record) => - doRequest('DELETE', path, undefined, headers), - request: ( - method: string, - path: string, - body?: unknown, - headers?: Record - ) => doRequest(method, path, body, headers), - }; -} diff --git a/vendor/bytelyst/platform-client/tsconfig.json b/vendor/bytelyst/platform-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/platform-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/push/package.json b/vendor/bytelyst/push/package.json deleted file mode 100644 index d8cb284..0000000 --- a/vendor/bytelyst/push/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@bytelyst/push", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/push/src/__tests__/push.test.ts b/vendor/bytelyst/push/src/__tests__/push.test.ts deleted file mode 100644 index a543e0b..0000000 --- a/vendor/bytelyst/push/src/__tests__/push.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Tests for push notification providers. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MockPushProvider } from '../providers/mock.js'; - -describe('MockPushProvider', () => { - let provider: MockPushProvider; - - beforeEach(() => { - provider = new MockPushProvider(); - }); - - it('isConfigured returns true', () => { - expect(provider.isConfigured()).toBe(true); - }); - - it('send records notification', async () => { - const result = await provider.send({ - deviceToken: 'token-123', - platform: 'ios', - title: 'Test', - body: 'Hello', - }); - expect(result.success).toBe(true); - expect(result.messageId).toBeDefined(); - expect(provider.sent).toHaveLength(1); - expect(provider.sent[0]!.title).toBe('Test'); - }); - - it('sendBatch records all notifications', async () => { - const results = await provider.sendBatch([ - { deviceToken: 't1', platform: 'ios', title: 'A', body: 'a' }, - { deviceToken: 't2', platform: 'android', title: 'B', body: 'b' }, - ]); - expect(results).toHaveLength(2); - expect(results.every(r => r.success)).toBe(true); - expect(provider.sent).toHaveLength(2); - }); - - it('reset clears sent history', async () => { - await provider.send({ deviceToken: 't', platform: 'web', title: 'X', body: 'x' }); - provider.reset(); - expect(provider.sent).toHaveLength(0); - }); -}); diff --git a/vendor/bytelyst/push/src/factory.ts b/vendor/bytelyst/push/src/factory.ts deleted file mode 100644 index b3693fb..0000000 --- a/vendor/bytelyst/push/src/factory.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Push notification provider factory. - * - * Creates a PushProvider based on PUSH_PROVIDER env var. - * Defaults to 'mock' since no push infra is wired yet. - */ - -import { ExpoPushProvider } from './providers/expo.js'; -import { MockPushProvider } from './providers/mock.js'; -import type { PushProvider, PushProviderType } from './types.js'; - -let _provider: PushProvider | null = null; - -export function getPush(): PushProvider { - if (!_provider) { - const type = (process.env.PUSH_PROVIDER || 'mock') as PushProviderType; - _provider = createPushProvider(type); - } - return _provider; -} - -export function createPushProvider(type: PushProviderType): PushProvider { - switch (type) { - case 'expo': - return new ExpoPushProvider(); - case 'firebase': - throw new Error('Firebase push provider not yet implemented. Use expo or mock.'); - case 'mock': - return new MockPushProvider(); - default: - throw new Error(`Unknown PUSH_PROVIDER: '${type}'. Valid: expo, firebase, mock`); - } -} - -export function setPush(provider: PushProvider): void { - _provider = provider; -} - -export function _resetPush(): void { - _provider = null; -} diff --git a/vendor/bytelyst/push/src/index.ts b/vendor/bytelyst/push/src/index.ts deleted file mode 100644 index b25caf6..0000000 --- a/vendor/bytelyst/push/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { PushProvider, PushNotification, PushResult, PushProviderType } from './types.js'; - -export { getPush, createPushProvider, setPush, _resetPush } from './factory.js'; -export { MockPushProvider } from './providers/mock.js'; -export { ExpoPushProvider } from './providers/expo.js'; diff --git a/vendor/bytelyst/push/src/providers/expo.ts b/vendor/bytelyst/push/src/providers/expo.ts deleted file mode 100644 index df27795..0000000 --- a/vendor/bytelyst/push/src/providers/expo.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Expo push notification provider. - * - * Uses the Expo push notification service REST API. - * No SDK dependency — just HTTP requests. - */ - -import type { PushNotification, PushProvider, PushResult } from '../types.js'; - -const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; - -export class ExpoPushProvider implements PushProvider { - isConfigured(): boolean { - return true; // Expo push is open — no API key required for basic use - } - - async send(notification: PushNotification): Promise { - const results = await this.sendBatch([notification]); - return results[0]!; - } - - async sendBatch(notifications: PushNotification[]): Promise { - const messages = notifications.map(n => ({ - to: n.deviceToken, - title: n.title, - body: n.body, - data: n.data, - badge: n.badge, - sound: n.sound ?? 'default', - channelId: n.channelId, - })); - - try { - const response = await fetch(EXPO_PUSH_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(messages), - }); - - if (!response.ok) { - const text = await response.text(); - return notifications.map(() => ({ - success: false, - error: `Expo push error ${response.status}: ${text}`, - })); - } - - const data = (await response.json()) as { - data: Array<{ status: string; id?: string; message?: string }>; - }; - - return data.data.map(ticket => ({ - success: ticket.status === 'ok', - messageId: ticket.id, - error: ticket.status !== 'ok' ? ticket.message : undefined, - })); - } catch (err) { - return notifications.map(() => ({ - success: false, - error: err instanceof Error ? err.message : 'Unknown error', - })); - } - } -} diff --git a/vendor/bytelyst/push/src/providers/mock.ts b/vendor/bytelyst/push/src/providers/mock.ts deleted file mode 100644 index 5e639fd..0000000 --- a/vendor/bytelyst/push/src/providers/mock.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Mock push notification provider — for testing and local dev. - * - * Logs notifications instead of sending them. - */ - -import type { PushNotification, PushProvider, PushResult } from '../types.js'; - -export class MockPushProvider implements PushProvider { - public sent: PushNotification[] = []; - - isConfigured(): boolean { - return true; - } - - async send(notification: PushNotification): Promise { - this.sent.push(notification); - return { success: true, messageId: `mock-${Date.now()}-${this.sent.length}` }; - } - - async sendBatch(notifications: PushNotification[]): Promise { - return Promise.all(notifications.map(n => this.send(n))); - } - - reset(): void { - this.sent = []; - } -} diff --git a/vendor/bytelyst/push/src/testing.ts b/vendor/bytelyst/push/src/testing.ts deleted file mode 100644 index 5461036..0000000 --- a/vendor/bytelyst/push/src/testing.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test helpers for @bytelyst/push. - */ - -import { setPush, _resetPush } from './factory.js'; -import { MockPushProvider } from './providers/mock.js'; - -export function setTestPushProvider(): MockPushProvider { - const provider = new MockPushProvider(); - setPush(provider); - return provider; -} - -export function resetTestPush(): void { - _resetPush(); -} - -export { MockPushProvider } from './providers/mock.js'; diff --git a/vendor/bytelyst/push/src/types.ts b/vendor/bytelyst/push/src/types.ts deleted file mode 100644 index 3779028..0000000 --- a/vendor/bytelyst/push/src/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Cloud-agnostic push notification interfaces. - * - * Provides a unified push API that works with - * Firebase, APNS, Expo, or mock/log providers. - */ - -export interface PushProvider { - /** Send a single push notification. */ - send(notification: PushNotification): Promise; - - /** Send a batch of push notifications. */ - sendBatch(notifications: PushNotification[]): Promise; - - /** Check if the push provider is configured. */ - isConfigured(): boolean; -} - -export interface PushNotification { - deviceToken: string; - platform: 'ios' | 'android' | 'web'; - title: string; - body: string; - data?: Record; - badge?: number; - sound?: string; - channelId?: string; -} - -export interface PushResult { - success: boolean; - messageId?: string; - error?: string; -} - -export type PushProviderType = 'firebase' | 'expo' | 'mock'; diff --git a/vendor/bytelyst/push/tsconfig.json b/vendor/bytelyst/push/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/push/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/queue/package.json b/vendor/bytelyst/queue/package.json deleted file mode 100644 index 3f13bc9..0000000 --- a/vendor/bytelyst/queue/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@bytelyst/queue", - "version": "0.1.5", - "description": "Durable job queue with pluggable stores and worker runtime", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "vitest": "^3.0.5" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/queue/src/file-store.ts b/vendor/bytelyst/queue/src/file-store.ts deleted file mode 100644 index 2484ac0..0000000 --- a/vendor/bytelyst/queue/src/file-store.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import type { EnqueueJobInput, ListJobsOptions, QueueJob, QueueStore } from './types.js'; - -export interface FileQueueStoreOptions { - filePath: string; -} - -export class FileQueueStore implements QueueStore { - private readonly filePath: string; - private operation = Promise.resolve(); - - constructor(options: FileQueueStoreOptions) { - this.filePath = options.filePath; - } - - enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - if (input.idempotencyKey) { - const existing = queue.find(job => job.idempotencyKey === input.idempotencyKey); - if (existing) return existing as QueueJob; - } - - const now = new Date(); - const job: QueueJob = { - id: input.id || randomUUID(), - queueName, - type: input.type, - payload: input.payload, - status: 'queued', - attempts: 0, - maxAttempts: input.maxAttempts ?? 3, - createdAt: now.toISOString(), - scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), - progress: input.progress, - metadata: input.metadata, - idempotencyKey: input.idempotencyKey, - productId: input.productId, - userId: input.userId, - }; - state[queueName] = [...queue, job]; - await this.writeState(state); - return job; - }); - } - - get( - queueName: string, - id: string - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const job = (state[queueName] ?? []).find(item => item.id === id); - return job as QueueJob | undefined; - }); - } - - list( - queueName: string, - options?: ListJobsOptions - ): Promise>> { - return this.withLock(async () => { - const state = await this.readState(); - const jobs = (state[queueName] ?? []) - .filter(job => !options?.status || job.status === options.status) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, options?.limit ?? 50); - return jobs as Array>; - }); - } - - claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now = new Date() - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - const next = queue - .filter(job => this.isClaimable(job, now)) - .sort( - (a, b) => - a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) - )[0]; - if (!next) return undefined; - - next.status = 'running'; - next.attempts += 1; - next.startedAt = next.startedAt || now.toISOString(); - next.leaseOwner = workerId; - next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); - await this.writeState(state); - return next as QueueJob; - }); - } - - patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined> { - return this.withLock(async () => { - const state = await this.readState(); - const queue = state[queueName] ?? []; - const index = queue.findIndex(job => job.id === id); - if (index === -1) return undefined; - - const merged = { - ...queue[index], - ...patch, - }; - queue[index] = merged; - state[queueName] = queue; - await this.writeState(state); - return merged as QueueJob; - }); - } - - clear(queueName?: string): Promise { - return this.withLock(async () => { - if (!queueName) { - await this.writeState({}); - return; - } - const state = await this.readState(); - delete state[queueName]; - await this.writeState(state); - }); - } - - private async withLock(fn: () => Promise): Promise { - const run = this.operation.then(fn, fn); - this.operation = run.then( - () => undefined, - () => undefined - ); - return run; - } - - private async readState(): Promise> { - try { - const raw = await readFile(this.filePath, 'utf-8'); - return JSON.parse(raw) as Record; - } catch { - return {}; - } - } - - private async writeState(state: Record): Promise { - await mkdir(dirname(this.filePath), { recursive: true }); - const tempPath = `${this.filePath}.${randomUUID()}.tmp`; - await writeFile(tempPath, JSON.stringify(state, null, 2), 'utf-8'); - await rename(tempPath, this.filePath); - } - - private isClaimable(job: QueueJob, now: Date): boolean { - if (job.status === 'queued') { - return job.scheduledAt <= now.toISOString(); - } - if (job.status === 'running') { - return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); - } - return false; - } -} diff --git a/vendor/bytelyst/queue/src/index.ts b/vendor/bytelyst/queue/src/index.ts deleted file mode 100644 index 0ed27dd..0000000 --- a/vendor/bytelyst/queue/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type { - EnqueueJobInput, - ListJobsOptions, - QueueHandler, - QueueJob, - QueueJobStatus, - QueueStore, - QueueWorkerOptions, - WorkerContext, -} from './types.js'; -export { MemoryQueueStore, isTerminalStatus } from './memory-store.js'; -export { FileQueueStore, type FileQueueStoreOptions } from './file-store.js'; -export { QueueWorker } from './worker.js'; diff --git a/vendor/bytelyst/queue/src/memory-store.ts b/vendor/bytelyst/queue/src/memory-store.ts deleted file mode 100644 index 4eb06d6..0000000 --- a/vendor/bytelyst/queue/src/memory-store.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { - EnqueueJobInput, - ListJobsOptions, - QueueJob, - QueueJobStatus, - QueueStore, -} from './types.js'; - -function cloneValue(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -export class MemoryQueueStore implements QueueStore { - private queues = new Map>(); - - async enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise> { - const queue = this.getQueue(queueName); - if (input.idempotencyKey) { - const existing = [...queue.values()].find(job => job.idempotencyKey === input.idempotencyKey); - if (existing) return cloneValue(existing) as QueueJob; - } - - const now = new Date(); - const job: QueueJob = { - id: input.id || randomUUID(), - queueName, - type: input.type, - payload: input.payload, - status: 'queued', - attempts: 0, - maxAttempts: input.maxAttempts ?? 3, - createdAt: now.toISOString(), - scheduledAt: new Date(now.getTime() + (input.delayMs ?? 0)).toISOString(), - progress: input.progress, - metadata: input.metadata, - idempotencyKey: input.idempotencyKey, - productId: input.productId, - userId: input.userId, - }; - queue.set(job.id, cloneValue(job)); - return cloneValue(job); - } - - async get( - queueName: string, - id: string - ): Promise | undefined> { - const job = this.getQueue(queueName).get(id); - return job ? (cloneValue(job) as QueueJob) : undefined; - } - - async list( - queueName: string, - options?: ListJobsOptions - ): Promise>> { - const jobs = [...this.getQueue(queueName).values()] - .filter(job => !options?.status || job.status === options.status) - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - return cloneValue(jobs.slice(0, options?.limit ?? 50)) as Array>; - } - - async claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now = new Date() - ): Promise | undefined> { - const queue = this.getQueue(queueName); - const candidates = [...queue.values()] - .filter(job => this.isClaimable(job, now)) - .sort( - (a, b) => - a.scheduledAt.localeCompare(b.scheduledAt) || a.createdAt.localeCompare(b.createdAt) - ); - const next = candidates[0]; - if (!next) return undefined; - - next.status = 'running'; - next.attempts += 1; - next.startedAt = next.startedAt || now.toISOString(); - next.leaseOwner = workerId; - next.leaseExpiresAt = new Date(now.getTime() + leaseMs).toISOString(); - queue.set(next.id, cloneValue(next)); - return cloneValue(next) as QueueJob; - } - - async patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined> { - const queue = this.getQueue(queueName); - const current = queue.get(id); - if (!current) return undefined; - - const merged = { - ...current, - ...patch, - }; - queue.set(id, cloneValue(merged)); - return cloneValue(merged) as QueueJob; - } - - async clear(queueName?: string): Promise { - if (queueName) { - this.queues.delete(queueName); - return; - } - this.queues.clear(); - } - - private getQueue(queueName: string): Map { - if (!this.queues.has(queueName)) { - this.queues.set(queueName, new Map()); - } - return this.queues.get(queueName)!; - } - - private isClaimable(job: QueueJob, now: Date): boolean { - if (job.status === 'queued') { - return job.scheduledAt <= now.toISOString(); - } - if (job.status === 'running') { - return !job.leaseExpiresAt || job.leaseExpiresAt <= now.toISOString(); - } - return false; - } -} - -export function isTerminalStatus(status: QueueJobStatus): boolean { - return ['succeeded', 'failed', 'dead_letter', 'cancelled'].includes(status); -} diff --git a/vendor/bytelyst/queue/src/queue.test.ts b/vendor/bytelyst/queue/src/queue.test.ts deleted file mode 100644 index a56c5c3..0000000 --- a/vendor/bytelyst/queue/src/queue.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { mkdtemp, readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { FileQueueStore } from './file-store.js'; -import { MemoryQueueStore } from './memory-store.js'; -import { QueueWorker } from './worker.js'; - -describe('MemoryQueueStore', () => { - it('enqueues and retrieves jobs', async () => { - const store = new MemoryQueueStore(); - const job = await store.enqueue('test', { - type: 'demo', - payload: { value: 1 }, - }); - - const found = await store.get('test', job.id); - expect(found?.payload).toEqual({ value: 1 }); - expect(found?.status).toBe('queued'); - }); -}); - -describe('FileQueueStore', () => { - it('persists jobs across store instances', async () => { - const dir = await mkdtemp(join(tmpdir(), 'queue-store-')); - const filePath = join(dir, 'jobs.json'); - - const first = new FileQueueStore({ filePath }); - const job = await first.enqueue('persisted', { - type: 'demo', - payload: { ok: true }, - }); - - const second = new FileQueueStore({ filePath }); - const found = await second.get('persisted', job.id); - expect(found?.payload).toEqual({ ok: true }); - - const raw = JSON.parse(await readFile(filePath, 'utf-8')) as Record< - string, - Array<{ id: string }> - >; - expect(raw.persisted[0].id).toBe(job.id); - }); -}); - -describe('QueueWorker', () => { - it('processes queued jobs to completion', async () => { - const store = new MemoryQueueStore(); - const worker = new QueueWorker<{ value: number }, { doubled: number }>({ - queueName: 'work', - store, - pollIntervalMs: 5, - handler: async job => ({ doubled: job.payload.value * 2 }), - }); - - worker.start(); - const job = await store.enqueue('work', { - type: 'double', - payload: { value: 21 }, - }); - - await vi.waitFor(async () => { - const updated = await store.get<{ value: number }, { doubled: number }>('work', job.id); - expect(updated?.status).toBe('succeeded'); - expect(updated?.result).toEqual({ doubled: 42 }); - }); - - await worker.stop(); - }); -}); diff --git a/vendor/bytelyst/queue/src/types.ts b/vendor/bytelyst/queue/src/types.ts deleted file mode 100644 index cafc7dd..0000000 --- a/vendor/bytelyst/queue/src/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -export type QueueJobStatus = - | 'queued' - | 'running' - | 'succeeded' - | 'failed' - | 'dead_letter' - | 'cancelled'; - -export interface QueueJob { - id: string; - queueName: string; - type: string; - payload: TPayload; - status: QueueJobStatus; - attempts: number; - maxAttempts: number; - createdAt: string; - scheduledAt: string; - startedAt?: string; - completedAt?: string; - lastError?: string; - result?: TResult; - progress?: Record; - metadata?: Record; - leaseOwner?: string; - leaseExpiresAt?: string; - idempotencyKey?: string; - productId?: string; - userId?: string; -} - -export interface EnqueueJobInput { - id?: string; - type: string; - payload: TPayload; - maxAttempts?: number; - delayMs?: number; - progress?: Record; - metadata?: Record; - idempotencyKey?: string; - productId?: string; - userId?: string; -} - -export interface ListJobsOptions { - limit?: number; - status?: QueueJobStatus; -} - -export interface QueueStore { - enqueue( - queueName: string, - input: EnqueueJobInput - ): Promise>; - get( - queueName: string, - id: string - ): Promise | undefined>; - list( - queueName: string, - options?: ListJobsOptions - ): Promise>>; - claimNext( - queueName: string, - workerId: string, - leaseMs: number, - now?: Date - ): Promise | undefined>; - patch( - queueName: string, - id: string, - patch: Partial> - ): Promise | undefined>; - clear(queueName?: string): Promise; -} - -export interface WorkerContext { - patch(patch: Partial>): Promise; - heartbeat(): Promise; -} - -export type QueueHandler = ( - job: QueueJob, - context: WorkerContext -) => Promise; - -export interface QueueWorkerOptions { - queueName: string; - store: QueueStore; - handler: QueueHandler; - workerId?: string; - pollIntervalMs?: number; - leaseMs?: number; - backoffMs?: number; -} diff --git a/vendor/bytelyst/queue/src/worker.ts b/vendor/bytelyst/queue/src/worker.ts deleted file mode 100644 index 7ed687c..0000000 --- a/vendor/bytelyst/queue/src/worker.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { QueueWorkerOptions, WorkerContext } from './types.js'; - -export class QueueWorker { - private readonly queueName: string; - private readonly store: QueueWorkerOptions['store']; - private readonly handler: QueueWorkerOptions['handler']; - private readonly workerId: string; - private readonly pollIntervalMs: number; - private readonly leaseMs: number; - private readonly backoffMs: number; - private timer?: ReturnType; - private running = false; - private inflight?: Promise; - - constructor(options: QueueWorkerOptions) { - this.queueName = options.queueName; - this.store = options.store; - this.handler = options.handler; - this.workerId = options.workerId || `worker_${randomUUID()}`; - this.pollIntervalMs = options.pollIntervalMs ?? 200; - this.leaseMs = options.leaseMs ?? 30_000; - this.backoffMs = options.backoffMs ?? 1_000; - } - - start(): void { - if (this.running) return; - this.running = true; - this.schedule(0); - } - - async stop(): Promise { - this.running = false; - if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; - } - await this.inflight; - } - - private schedule(delayMs: number): void { - if (!this.running) return; - this.timer = globalThis.setTimeout(() => { - this.inflight = this.tick().finally(() => { - this.inflight = undefined; - }); - }, delayMs); - } - - private async tick(): Promise { - const job = await this.store.claimNext( - this.queueName, - this.workerId, - this.leaseMs - ); - if (!job) { - this.schedule(this.pollIntervalMs); - return; - } - - const context: WorkerContext = { - patch: async patch => { - await this.store.patch(this.queueName, job.id, patch); - }, - heartbeat: async () => { - await this.store.patch(this.queueName, job.id, { - leaseOwner: this.workerId, - leaseExpiresAt: new Date(Date.now() + this.leaseMs).toISOString(), - }); - }, - }; - - try { - const result = await this.handler(job, context); - await this.store.patch(this.queueName, job.id, { - status: 'succeeded', - result, - completedAt: new Date().toISOString(), - leaseOwner: undefined, - leaseExpiresAt: undefined, - }); - } catch (err: unknown) { - const lastError = err instanceof Error ? err.message : String(err); - const finalStatus = job.attempts >= job.maxAttempts ? 'dead_letter' : 'queued'; - await this.store.patch(this.queueName, job.id, { - status: finalStatus, - lastError, - scheduledAt: - finalStatus === 'queued' - ? new Date(Date.now() + this.backoffMs * job.attempts).toISOString() - : job.scheduledAt, - completedAt: finalStatus === 'dead_letter' ? new Date().toISOString() : undefined, - leaseOwner: undefined, - leaseExpiresAt: undefined, - }); - } - - this.schedule(0); - } -} diff --git a/vendor/bytelyst/queue/tsconfig.json b/vendor/bytelyst/queue/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/queue/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/quick-actions/package.json b/vendor/bytelyst/quick-actions/package.json deleted file mode 100644 index 0237e0c..0000000 --- a/vendor/bytelyst/quick-actions/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/quick-actions", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/quick-actions/src/client.test.ts b/vendor/bytelyst/quick-actions/src/client.test.ts deleted file mode 100644 index 56ebf00..0000000 --- a/vendor/bytelyst/quick-actions/src/client.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - getVisibleSections, - getAvailableActions, - pickSmartDefault, - MAX_VISIBLE_ITEMS, - MAX_VISIBLE_LIST, -} from './client.js'; -import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; - -describe('getVisibleSections', () => { - const sections: ProgressiveSection[] = [ - { id: 'main', title: 'Main', defaultExpanded: true, priority: 'primary' }, - { id: 'stats', title: 'Stats', defaultExpanded: false, priority: 'secondary' }, - { id: 'details', title: 'Details', defaultExpanded: false, priority: 'detail' }, - { id: 'advanced', title: 'Advanced', defaultExpanded: false, priority: 'primary' }, - ]; - - it('should show primary and defaultExpanded sections', () => { - const visible = getVisibleSections(sections, new Set()); - expect(visible.map(s => s.id)).toEqual(['main', 'advanced']); - }); - - it('should include explicitly expanded sections', () => { - const visible = getVisibleSections(sections, new Set(['stats'])); - expect(visible.map(s => s.id)).toEqual(['main', 'stats', 'advanced']); - }); - - it('should return empty for no sections', () => { - expect(getVisibleSections([], new Set())).toHaveLength(0); - }); -}); - -describe('getAvailableActions', () => { - const actions: QuickAction[] = [ - { - id: 'start', - label: 'Start', - icon: 'play', - shortLabel: 'Start', - action: 'start', - requiresAuth: false, - }, - { - id: 'share', - label: 'Share', - icon: 'share', - shortLabel: 'Share', - action: 'share', - requiresAuth: true, - }, - { - id: 'stop', - label: 'Stop', - icon: 'stop', - shortLabel: 'Stop', - action: 'stop', - requiresAuth: false, - }, - ]; - - it('should filter out auth-required actions when not authenticated', () => { - const available = getAvailableActions(actions, { isAuthenticated: false }); - expect(available.map(a => a.id)).toEqual(['start', 'stop']); - }); - - it('should include all when authenticated', () => { - const available = getAvailableActions(actions, { isAuthenticated: true }); - expect(available).toHaveLength(3); - }); - - it('should include non-auth actions by default', () => { - const available = getAvailableActions(actions, {}); - expect(available.map(a => a.id)).toEqual(['start', 'stop']); - }); -}); - -describe('pickSmartDefault', () => { - it('should prefer last_used over others', () => { - const candidates: SmartDefault[] = [ - { key: 'protocol', value: 'OMAD', source: 'most_common' }, - { key: 'protocol', value: '16:8', source: 'last_used' }, - { key: 'protocol', value: '18:6', source: 'recommendation' }, - ]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('16:8'); - }); - - it('should fall back to most_common', () => { - const candidates: SmartDefault[] = [ - { key: 'protocol', value: 'OMAD', source: 'most_common' }, - { key: 'protocol', value: '18:6', source: 'system' }, - ]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('OMAD'); - }); - - it('should return null for empty array', () => { - expect(pickSmartDefault([])).toBeNull(); - }); - - it('should return first if no priority match', () => { - const candidates: SmartDefault[] = [{ key: 'x', value: 'a', source: 'system' }]; - const result = pickSmartDefault(candidates); - expect(result?.value).toBe('a'); - }); -}); - -describe('constants', () => { - it('should export MAX_VISIBLE_ITEMS', () => { - expect(MAX_VISIBLE_ITEMS).toBe(3); - }); - - it('should export MAX_VISIBLE_LIST', () => { - expect(MAX_VISIBLE_LIST).toBe(5); - }); -}); diff --git a/vendor/bytelyst/quick-actions/src/client.ts b/vendor/bytelyst/quick-actions/src/client.ts deleted file mode 100644 index 422b0be..0000000 --- a/vendor/bytelyst/quick-actions/src/client.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Progressive disclosure system, smart defaults, quick action definitions. - * - * Reduces cognitive load by surfacing only relevant UI sections and actions. - * Pure client-side TS — no backend dependency. - */ - -import type { ProgressiveSection, QuickAction, SmartDefault } from './types.js'; - -export const MAX_VISIBLE_ITEMS = 3; -export const MAX_VISIBLE_LIST = 5; - -export function getVisibleSections( - sections: ProgressiveSection[], - expandedIds: Set -): ProgressiveSection[] { - return sections.filter( - s => s.defaultExpanded || expandedIds.has(s.id) || s.priority === 'primary' - ); -} - -export function getAvailableActions( - actions: QuickAction[], - context: { isActive?: boolean; isAuthenticated?: boolean } -): QuickAction[] { - return actions.filter(a => { - if (a.requiresAuth && !context.isAuthenticated) return false; - return true; - }); -} - -export function pickSmartDefault(candidates: SmartDefault[]): SmartDefault | null { - if (candidates.length === 0) return null; - - const priority: SmartDefault['source'][] = [ - 'last_used', - 'most_common', - 'recommendation', - 'system', - ]; - for (const source of priority) { - const match = candidates.find(c => c.source === source); - if (match) return match; - } - - return candidates[0]; -} diff --git a/vendor/bytelyst/quick-actions/src/index.ts b/vendor/bytelyst/quick-actions/src/index.ts deleted file mode 100644 index 08b63e9..0000000 --- a/vendor/bytelyst/quick-actions/src/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface QuickAction { - id: string; - label: string; - icon?: string; - requiresAuth?: boolean; - priority?: number; -} - -export const MAX_VISIBLE_ITEMS = 4; -export const MAX_VISIBLE_LIST = 8; - -export function getAvailableActions( - actions: QuickAction[], - opts?: { isAuthenticated?: boolean }, -): QuickAction[] { - const isAuthenticated = opts?.isAuthenticated ?? false; - return actions.filter( - (a) => !a.requiresAuth || isAuthenticated, - ); -} - -export function pickSmartDefault(defaults: QuickAction[]): QuickAction | null { - if (defaults.length === 0) { - return null; - } - return defaults[0] ?? null; -} diff --git a/vendor/bytelyst/quick-actions/src/types.ts b/vendor/bytelyst/quick-actions/src/types.ts deleted file mode 100644 index ce86fdd..0000000 --- a/vendor/bytelyst/quick-actions/src/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Types for @bytelyst/quick-actions. - * Pure client-side TS — no backend dependency. - */ - -export interface QuickAction { - id: string; - label: string; - icon: string; - shortLabel: string; - action: string; - requiresAuth: boolean; -} - -export interface ProgressiveSection { - id: string; - title: string; - defaultExpanded: boolean; - priority: 'primary' | 'secondary' | 'detail'; -} - -export interface SmartDefault { - key: string; - value: unknown; - source: 'last_used' | 'most_common' | 'recommendation' | 'system'; -} diff --git a/vendor/bytelyst/quick-actions/tsconfig.json b/vendor/bytelyst/quick-actions/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/quick-actions/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/react-auth/package.json b/vendor/bytelyst/react-auth/package.json deleted file mode 100644 index 0d04cc7..0000000 --- a/vendor/bytelyst/react-auth/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@bytelyst/react-auth", - "version": "0.1.6", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": { - "react": ">=18.0.0" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "happy-dom": "^18.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx deleted file mode 100644 index 303f884..0000000 --- a/vendor/bytelyst/react-auth/src/__tests__/react-auth.test.tsx +++ /dev/null @@ -1,479 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, act, cleanup } from '@testing-library/react'; -import { createAuthProvider } from '../index.js'; - -// Minimal user type for testing -interface TestUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -// Mock fetch globally -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -// localStorage mock -const store: Record = {}; -const localStorageMock = { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - for (const key of Object.keys(store)) delete store[key]; - }), - length: 0, - key: vi.fn(), -}; -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); - -function createTestAuth(overrides?: Partial>[0]>) { - return createAuthProvider({ - storagePrefix: 'test', - loginEndpoint: '/auth/login', - mapLoginResponse: (data: unknown) => { - const d = data as { user: TestUser; accessToken: string; refreshToken: string }; - return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; - }, - ...overrides, - }); -} - -describe('createAuthProvider', () => { - beforeEach(() => { - cleanup(); - localStorageMock.clear(); - vi.clearAllMocks(); - mockFetch.mockReset(); - }); - - it('returns AuthProvider and useAuth', () => { - const result = createTestAuth(); - expect(result.AuthProvider).toBeDefined(); - expect(result.useAuth).toBeDefined(); - expect(typeof result.AuthProvider).toBe('function'); - expect(typeof result.useAuth).toBe('function'); - }); - - it('renders children', () => { - const { AuthProvider } = createTestAuth(); - render( - -
Hello
-
- ); - expect(screen.getByTestId('child').textContent).toBe('Hello'); - }); - - it('starts unauthenticated with no stored user', () => { - const { AuthProvider, useAuth } = createTestAuth(); - function Display() { - const { user, isAuthenticated, isLoading } = useAuth(); - return ( -
- {String(isAuthenticated)} - {String(isLoading)} - {user ? user.email : 'none'} -
- ); - } - render( - - - - ); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('loading').textContent).toBe('false'); - expect(screen.getByTestId('user').textContent).toBe('none'); - }); - - it('restores user from localStorage on mount', () => { - const storedUser: TestUser = { email: 'a@b.com', name: 'Stored', role: 'user' }; - store['test_auth_user'] = JSON.stringify(storedUser); - - const { AuthProvider, useAuth } = createTestAuth(); - function Display() { - const { user, isAuthenticated } = useAuth(); - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - render( - - - - ); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('a@b.com'); - }); - - it('login stores user and tokens on success', async () => { - const apiResponse = { - user: { email: 'test@example.com', name: 'Test' }, - accessToken: 'at-123', - refreshToken: 'rt-456', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - - function LoginComponent() { - const { login, user, isAuthenticated } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - - let result: boolean = false; - await act(async () => { - result = await loginFn!('test@example.com', 'pass123'); - }); - - expect(result).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('test@example.com'); - expect(mockFetch).toHaveBeenCalledWith( - '/api/auth/login', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ email: 'test@example.com', password: 'pass123' }), - }) - ); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'test_auth_user', - expect.stringContaining('test@example.com') - ); - expect(localStorageMock.setItem).toHaveBeenCalledWith('test_access_token', 'at-123'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('test_refresh_token', 'rt-456'); - }); - - it('login returns false on API failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: async () => ({ error: 'Unauthorized' }), - headers: new Headers({ 'content-type': 'application/json' }), - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - - function LoginComponent() { - const { login, isAuthenticated, error } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {error ?? 'none'} -
- ); - } - - render( - - - - ); - - let result: boolean = false; - await act(async () => { - result = await loginFn!('bad@example.com', 'wrong'); - }); - - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('error').textContent).toBe('Unauthorized'); - }); - - it('logout clears user and storage', async () => { - store['test_auth_user'] = JSON.stringify({ email: 'a@b.com', name: 'A', role: 'admin' }); - store['test_access_token'] = 'token'; - store['test_refresh_token'] = 'refresh'; - - const onLogout = vi.fn(); - const { AuthProvider, useAuth } = createTestAuth({ onLogout }); - let logoutFn: () => void; - - function Component() { - const { logout, isAuthenticated } = useAuth(); - logoutFn = logout; - return {String(isAuthenticated)}; - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - - act(() => { - logoutFn!(); - }); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_auth_user'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_access_token'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_refresh_token'); - expect(onLogout).toHaveBeenCalledOnce(); - }); - - it('useAuth throws outside AuthProvider', () => { - const { useAuth } = createTestAuth(); - function Bad() { - useAuth(); - return null; - } - expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); - }); - - it('calls onLoginFallback when API fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const fallbackUser: TestUser = { email: 'mock@test.com', name: 'Mock', role: 'user' }; - const onLoginFallback = vi.fn().mockResolvedValue({ - user: fallbackUser, - accessToken: 'fallback-at', - refreshToken: 'fallback-rt', - }); - - const { AuthProvider, useAuth } = createTestAuth({ onLoginFallback }); - let loginFn: (email: string, password: string) => Promise; - - function Component() { - const { login, user } = useAuth(); - loginFn = login; - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - let result = false; - await act(async () => { - result = await loginFn!('mock@test.com', 'pass'); - }); - - expect(result).toBe(true); - expect(onLoginFallback).toHaveBeenCalledWith('mock@test.com', 'pass', expect.any(String)); - expect(screen.getByTestId('email').textContent).toBe('mock@test.com'); - }); - - it('updateUser merges partial updates into user state', async () => { - const apiResponse = { - user: { email: 'test@example.com', name: 'Original', role: 'user' }, - accessToken: 'at-1', - refreshToken: 'rt-1', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let updateUserFn: (updates: Partial) => void; - - function Component() { - const { login, updateUser, user } = useAuth(); - loginFn = login; - updateUserFn = updateUser; - return ( -
- {user?.name ?? 'none'} - {user?.role ?? 'none'} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('test@example.com', 'pass'); - }); - expect(screen.getByTestId('name').textContent).toBe('Original'); - - act(() => { - updateUserFn!({ name: 'Updated' }); - }); - - expect(screen.getByTestId('name').textContent).toBe('Updated'); - expect(screen.getByTestId('role').textContent).toBe('user'); - // Verify localStorage was updated too - const storedUser = JSON.parse(store['test_auth_user']); - expect(storedUser.name).toBe('Updated'); - expect(storedUser.role).toBe('user'); - }); - - it('updateUser is a no-op when no user is logged in', () => { - const { AuthProvider, useAuth } = createTestAuth(); - let updateUserFn: (updates: Partial) => void; - - function Component() { - const { updateUser, user } = useAuth(); - updateUserFn = updateUser; - return {user ? 'yes' : 'no'}; - } - - render( - - - - ); - - act(() => { - updateUserFn!({ name: 'Should not crash' }); - }); - - expect(screen.getByTestId('user').textContent).toBe('no'); - }); - - it('onInit provides initial session from external source', () => { - const initUser: TestUser = { email: 'sso@corp.com', name: 'SSO User', role: 'admin' }; - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => ({ - user: initUser, - accessToken: 'sso-at', - refreshToken: 'sso-rt', - }), - }); - - function Display() { - const { user, isAuthenticated } = useAuth(); - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('sso@corp.com'); - // Verify tokens were saved - expect(store['test_access_token']).toBe('sso-at'); - expect(store['test_refresh_token']).toBe('sso-rt'); - }); - - it('onInit returning null falls through to localStorage', () => { - store['test_auth_user'] = JSON.stringify({ - email: 'stored@local.com', - name: 'Local', - role: 'user', - }); - - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => null, - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('stored@local.com'); - }); - - it('onInit takes priority over localStorage when it returns a session', () => { - store['test_auth_user'] = JSON.stringify({ - email: 'stored@local.com', - name: 'Local', - role: 'user', - }); - - const { AuthProvider, useAuth } = createTestAuth({ - onInit: () => ({ - user: { email: 'init@override.com', name: 'Init', role: 'admin' }, - accessToken: 'init-at', - refreshToken: 'init-rt', - }), - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('init@override.com'); - }); - - it('uses correct storage prefix for keys', () => { - const storedUser: TestUser = { email: 'x@y.com', name: 'X', role: 'viewer' }; - store['custom_auth_user'] = JSON.stringify(storedUser); - - const { AuthProvider, useAuth } = createAuthProvider({ - storagePrefix: 'custom', - loginEndpoint: '/login', - mapLoginResponse: (d: unknown) => - d as { user: TestUser; accessToken: string; refreshToken: string }, - }); - - function Display() { - const { user } = useAuth(); - return {user?.email ?? 'none'}; - } - - render( - - - - ); - - expect(screen.getByTestId('email').textContent).toBe('x@y.com'); - }); -}); diff --git a/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx b/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx deleted file mode 100644 index 26c687e..0000000 --- a/vendor/bytelyst/react-auth/src/__tests__/smartauth.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -// @vitest-environment happy-dom -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, act, cleanup } from '@testing-library/react'; -import { createAuthProvider } from '../index.js'; - -interface TestUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -const mockFetch = vi.fn(); -globalThis.fetch = mockFetch; - -const store: Record = {}; -const localStorageMock = { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - for (const key of Object.keys(store)) delete store[key]; - }), - length: 0, - key: vi.fn(), -}; -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); - -function createTestAuth(overrides?: Partial>[0]>) { - return createAuthProvider({ - storagePrefix: 'sa', - loginEndpoint: '/auth/login', - mapLoginResponse: (data: unknown) => { - const d = data as { user: TestUser; accessToken: string; refreshToken: string }; - return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken }; - }, - ...overrides, - }); -} - -describe('react-auth SmartAuth features', () => { - beforeEach(() => { - cleanup(); - localStorageMock.clear(); - vi.clearAllMocks(); - mockFetch.mockReset(); - }); - - // ── Phase 1C: loginWithGoogle ────────────────────── - - it('loginWithGoogle sets user on success', async () => { - const apiResponse = { - user: { email: 'g@gmail.com', name: 'Google User', role: 'user' }, - accessToken: 'g-at', - refreshToken: 'g-rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => apiResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginWithGoogleFn: (idToken: string) => Promise; - - function Component() { - const { loginWithGoogle, user, isAuthenticated } = useAuth(); - loginWithGoogleFn = loginWithGoogle; - return ( -
- {String(isAuthenticated)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - - let result = false; - await act(async () => { - result = await loginWithGoogleFn!('google-id-token'); - }); - - expect(result).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('email').textContent).toBe('g@gmail.com'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'g-at'); - }); - - it('loginWithGoogle returns false on API failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: async () => ({ error: 'Invalid token' }), - headers: new Headers({ 'content-type': 'application/json' }), - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginWithGoogleFn: (idToken: string) => Promise; - - function Component() { - const { loginWithGoogle, isAuthenticated, error } = useAuth(); - loginWithGoogleFn = loginWithGoogle; - return ( -
- {String(isAuthenticated)} - {error ?? 'none'} -
- ); - } - - render( - - - - ); - - let result = true; - await act(async () => { - result = await loginWithGoogleFn!('bad-token'); - }); - - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - }); - - // ── Phase 2D: MFA challenge flow ────────────────── - - it('login triggers MFA state when mfaRequired returned', async () => { - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'challenge-xyz', - methods: ['totp'], - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mfaResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const onMfaRequired = vi.fn(); - const { AuthProvider, useAuth } = createTestAuth({ onMfaRequired }); - let loginFn: (email: string, password: string) => Promise; - - function Component() { - const { login, mfaRequired, mfaChallenge, mfaMethods, isAuthenticated } = useAuth(); - loginFn = login; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} - {mfaChallenge ?? 'none'} - {mfaMethods.join(',') || 'none'} -
- ); - } - - render( - - - - ); - - let result = true; - await act(async () => { - result = await loginFn!('user@test.com', 'pass'); - }); - - // Login returns false (MFA required, not yet authenticated) - expect(result).toBe(false); - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('mfa').textContent).toBe('true'); - expect(screen.getByTestId('challenge').textContent).toBe('challenge-xyz'); - expect(screen.getByTestId('methods').textContent).toBe('totp'); - expect(onMfaRequired).toHaveBeenCalledWith('challenge-xyz', ['totp']); - }); - - it('verifyMfa completes login after MFA challenge', async () => { - // Step 1: login triggers MFA - const mfaResponse = { - mfaRequired: true, - mfaChallenge: 'challenge-abc', - methods: ['totp'], - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mfaResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let verifyMfaFn: (code: string, method: 'totp' | 'recovery') => Promise; - - function Component() { - const { login, verifyMfa, mfaRequired, isAuthenticated, user } = useAuth(); - loginFn = login; - verifyMfaFn = verifyMfa; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} - {user?.email ?? 'none'} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('user@test.com', 'pass'); - }); - - expect(screen.getByTestId('mfa').textContent).toBe('true'); - expect(screen.getByTestId('auth').textContent).toBe('false'); - - // Step 2: verify MFA - const verifyResponse = { - user: { email: 'user@test.com', name: 'Test', role: 'user' }, - accessToken: 'mfa-at', - refreshToken: 'mfa-rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => verifyResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - let verifyResult = false; - await act(async () => { - verifyResult = await verifyMfaFn!('123456', 'totp'); - }); - - expect(verifyResult).toBe(true); - expect(screen.getByTestId('auth').textContent).toBe('true'); - expect(screen.getByTestId('mfa').textContent).toBe('false'); - expect(screen.getByTestId('email').textContent).toBe('user@test.com'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('sa_access_token', 'mfa-at'); - }); - - // ── Phase 1C: Provider management ───────────────── - - it('providers starts empty and exposes link/unlink', () => { - const { AuthProvider, useAuth } = createTestAuth(); - - function Component() { - const { providers, linkProvider, unlinkProvider, refreshProviders } = useAuth(); - return ( -
- {providers.length} - {String(typeof linkProvider === 'function')} - {String(typeof unlinkProvider === 'function')} - {String(typeof refreshProviders === 'function')} -
- ); - } - - render( - - - - ); - - expect(screen.getByTestId('count').textContent).toBe('0'); - expect(screen.getByTestId('hasLink').textContent).toBe('true'); - expect(screen.getByTestId('hasUnlink').textContent).toBe('true'); - expect(screen.getByTestId('hasRefresh').textContent).toBe('true'); - }); - - // ── Logout clears SmartAuth state ───────────────── - - it('logout clears providers and MFA state', async () => { - // Login first - const loginResponse = { - user: { email: 'a@b.com', name: 'A', role: 'user' }, - accessToken: 'at', - refreshToken: 'rt', - }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => loginResponse, - headers: new Headers({ 'content-type': 'application/json' }), - status: 200, - }); - - const { AuthProvider, useAuth } = createTestAuth(); - let loginFn: (email: string, password: string) => Promise; - let logoutFn: () => void; - - function Component() { - const { login, logout, isAuthenticated, mfaRequired } = useAuth(); - loginFn = login; - logoutFn = logout; - return ( -
- {String(isAuthenticated)} - {String(mfaRequired)} -
- ); - } - - render( - - - - ); - - await act(async () => { - await loginFn!('a@b.com', 'pass'); - }); - - expect(screen.getByTestId('auth').textContent).toBe('true'); - - act(() => { - logoutFn!(); - }); - - expect(screen.getByTestId('auth').textContent).toBe('false'); - expect(screen.getByTestId('mfa').textContent).toBe('false'); - }); -}); diff --git a/vendor/bytelyst/react-auth/src/auth-context.tsx b/vendor/bytelyst/react-auth/src/auth-context.tsx deleted file mode 100644 index b2be03c..0000000 --- a/vendor/bytelyst/react-auth/src/auth-context.tsx +++ /dev/null @@ -1,551 +0,0 @@ -'use client'; - -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - useRef, - type ReactNode, -} from 'react'; -import { createApiClient } from '@bytelyst/api-client'; -import type { AuthConfig, AuthContextValue, AuthProviderInfo, BaseUser } from './types.js'; - -/** - * Create a typed auth provider + hook for a specific user type. - * - * Supports the full auth lifecycle: login, register, forgot password, - * change password, delete account, and automatic token refresh. - * - * @example - * ```tsx - * const { AuthProvider, useAuth } = createAuthProvider({ - * storagePrefix: "admin", - * loginEndpoint: "/auth/login", - * registerEndpoint: "/auth/register", - * forgotPasswordEndpoint: "/auth/forgot-password", - * changePasswordEndpoint: "/auth/change-password", - * deleteAccountEndpoint: "/auth/delete-account", - * refreshEndpoint: "/auth/refresh", - * mapLoginResponse: (data) => ({ - * user: data.user, - * accessToken: data.accessToken, - * refreshToken: data.refreshToken, - * }), - * }); - * ``` - */ -export function createAuthProvider(config: AuthConfig) { - const { - baseUrl: configBaseUrl = '/api', - storagePrefix, - loginEndpoint, - registerEndpoint, - forgotPasswordEndpoint, - changePasswordEndpoint, - deleteAccountEndpoint, - refreshEndpoint, - refreshIntervalMs = 45 * 60 * 1000, - mapLoginResponse, - onLoginFallback, - onInit, - onLogout, - oauthEndpoint = '/auth/oauth', - providersEndpoint = '/auth/providers', - linkProviderEndpoint = '/auth/providers/link', - mfaVerifyEndpoint = '/auth/mfa/verify', - onMfaRequired, - productId: configProductId, - } = config; - - const USER_KEY = `${storagePrefix}_auth_user`; - const TOKEN_KEY = `${storagePrefix}_access_token`; - const REFRESH_KEY = `${storagePrefix}_refresh_token`; - - const AuthContext = createContext | null>(null); - - function getStoredUser(): TUser | null { - if (typeof window === 'undefined') return null; - try { - const stored = localStorage.getItem(USER_KEY); - return stored ? JSON.parse(stored) : null; - } catch { - return null; - } - } - - function saveSession(user: TUser, accessToken: string, refreshToken: string) { - localStorage.setItem(USER_KEY, JSON.stringify(user)); - localStorage.setItem(TOKEN_KEY, accessToken); - localStorage.setItem(REFRESH_KEY, refreshToken); - } - - function clearSession() { - localStorage.removeItem(USER_KEY); - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_KEY); - } - - function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(() => { - // Allow onInit to provide an initial session (e.g. from SSO cookies) - if (onInit) { - const initResult = onInit(); - if (initResult) { - saveSession(initResult.user, initResult.accessToken, initResult.refreshToken); - return initResult.user; - } - } - return getStoredUser(); - }); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const [providers, setProviders] = useState([]); - const [mfaRequired, setMfaRequired] = useState(false); - const [mfaMethods, setMfaMethods] = useState([]); - const [mfaChallenge, setMfaChallenge] = useState(null); - const refreshTimerRef = useRef | null>(null); - - const api = createApiClient({ - baseUrl: configBaseUrl, - getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null), - }); - - const clearMessages = useCallback(() => { - setError(null); - setSuccess(null); - }, []); - - // ── Token refresh ────────────────────────────── - - const refreshAccessToken = useCallback(async () => { - if (!refreshEndpoint) return; - const rt = typeof window !== 'undefined' ? localStorage.getItem(REFRESH_KEY) : null; - if (!rt) return; - - try { - const data = await api.fetch<{ accessToken: string; refreshToken: string }>( - refreshEndpoint, - { method: 'POST', body: JSON.stringify({ refreshToken: rt }) } - ); - localStorage.setItem(TOKEN_KEY, data.accessToken); - localStorage.setItem(REFRESH_KEY, data.refreshToken); - } catch { - // Token expired — force logout - setUser(null); - clearSession(); - onLogout?.(); - } - }, [api]); - - useEffect(() => { - if (!user || !refreshEndpoint) return; - refreshTimerRef.current = setInterval(refreshAccessToken, refreshIntervalMs); - return () => { - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - }; - }, [user, refreshAccessToken, refreshIntervalMs]); - - // ── MFA challenge helper ───────────────────────── - - function handleMfaChallenge(data: Record): boolean { - if (data && typeof data === 'object' && 'mfaRequired' in data && data.mfaRequired === true) { - const challenge = data.mfaChallenge as string; - const methods = data.methods as string[]; - setMfaRequired(true); - setMfaChallenge(challenge); - setMfaMethods(methods ?? []); - onMfaRequired?.(challenge, methods ?? []); - return true; - } - return false; - } - - // ── Login ────────────────────────────────────── - - const login = useCallback( - async (email: string, password: string) => { - setIsLoading(true); - setError(null); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - try { - const { data, error: fetchError } = await api.safeFetch(loginEndpoint, { - method: 'POST', - body: JSON.stringify( - configProductId - ? { email, password, productId: configProductId } - : { email, password } - ), - }); - - if (data && !fetchError) { - if (handleMfaChallenge(data as Record)) { - return false; - } - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - if (fetchError && onLoginFallback) { - const fallback = await onLoginFallback(email, password, fetchError); - if (fallback) { - setUser(fallback.user); - saveSession(fallback.user, fallback.accessToken, fallback.refreshToken); - return true; - } - } - - setError(fetchError || 'Login failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Register ─────────────────────────────────── - - const register = useCallback( - async (email: string, password: string, displayName: string) => { - if (!registerEndpoint) { - setError('Registration not supported'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { data, error: fetchError } = await api.safeFetch(registerEndpoint, { - method: 'POST', - body: JSON.stringify( - configProductId - ? { email, password, displayName, productId: configProductId } - : { email, password, displayName } - ), - }); - - if (data && !fetchError) { - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - setError(fetchError || 'Registration failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Social login (Phase 1C) ──────────────────── - - const loginWithOAuth = useCallback( - async (provider: string, idToken: string) => { - setIsLoading(true); - setError(null); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - try { - const oauthBody: Record = { idToken }; - if (configProductId) oauthBody.productId = configProductId; - const { data, error: fetchError } = await api.safeFetch( - `${oauthEndpoint}/${provider}`, - { method: 'POST', body: JSON.stringify(oauthBody) } - ); - - if (data && !fetchError) { - if (handleMfaChallenge(data as Record)) { - return false; - } - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - return true; - } - - setError(fetchError || `${provider} login failed`); - return false; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - const loginWithGoogle = useCallback( - (idToken: string) => loginWithOAuth('google', idToken), - [loginWithOAuth] - ); - - const loginWithMicrosoft = useCallback( - (idToken: string) => loginWithOAuth('microsoft', idToken), - [loginWithOAuth] - ); - - const loginWithApple = useCallback( - (idToken: string) => loginWithOAuth('apple', idToken), - [loginWithOAuth] - ); - - // ── Provider management (Phase 1C) ──────────── - - const refreshProviders = useCallback(async () => { - try { - const data = await api.fetch<{ providers: AuthProviderInfo[] }>(providersEndpoint, { - method: 'GET', - }); - setProviders(data.providers ?? []); - } catch { - // non-fatal — providers list is supplementary - } - }, [api]); - - const linkProvider = useCallback( - async (provider: string, idToken: string) => { - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch(linkProviderEndpoint, { - method: 'POST', - body: JSON.stringify({ provider, idToken }), - }); - if (fetchError) { - setError(fetchError); - return false; - } - await refreshProviders(); - return true; - } finally { - setIsLoading(false); - } - }, - [api, refreshProviders] - ); - - const unlinkProvider = useCallback( - async (provider: string) => { - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch( - `${providersEndpoint}/${provider}`, - { method: 'DELETE' } - ); - if (fetchError) { - setError(fetchError); - return false; - } - await refreshProviders(); - return true; - } finally { - setIsLoading(false); - } - }, - [api, refreshProviders] - ); - - // ── MFA verify (Phase 2D) ───────────────────── - - const verifyMfa = useCallback( - async (code: string, method: 'totp' | 'recovery') => { - if (!mfaChallenge) { - setError('No MFA challenge in progress'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { data, error: fetchError } = await api.safeFetch(mfaVerifyEndpoint, { - method: 'POST', - body: JSON.stringify({ challengeToken: mfaChallenge, code, method }), - }); - - if (data && !fetchError) { - const mapped = mapLoginResponse(data); - setUser(mapped.user); - saveSession(mapped.user, mapped.accessToken, mapped.refreshToken); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - return true; - } - - setError(fetchError || 'MFA verification failed'); - return false; - } finally { - setIsLoading(false); - } - }, - [api, mfaChallenge] - ); - - // ── Logout ───────────────────────────────────── - - const logout = useCallback(() => { - setUser(null); - clearSession(); - setProviders([]); - setMfaRequired(false); - setMfaChallenge(null); - setMfaMethods([]); - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - onLogout?.(); - }, []); - - // ── Forgot password ──────────────────────────── - - const forgotPassword = useCallback( - async (email: string) => { - if (!forgotPasswordEndpoint) { - setError('Forgot password not supported'); - return false; - } - setIsLoading(true); - setError(null); - setSuccess(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - forgotPasswordEndpoint, - { method: 'POST', body: JSON.stringify({ email }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setSuccess('If that email exists, a reset link has been sent.'); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Change password ──────────────────────────── - - const changePassword = useCallback( - async (currentPassword: string, newPassword: string) => { - if (!changePasswordEndpoint) { - setError('Change password not supported'); - return false; - } - setIsLoading(true); - setError(null); - setSuccess(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - changePasswordEndpoint, - { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setSuccess('Password changed successfully.'); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - // ── Update user (local state + localStorage) ── - - const updateUser = useCallback((updates: Partial) => { - setUser(prev => { - if (!prev) return null; - const updated = { ...prev, ...updates }; - localStorage.setItem(USER_KEY, JSON.stringify(updated)); - return updated; - }); - }, []); - - // ── Delete account ───────────────────────────── - - const deleteAccount = useCallback( - async (password: string) => { - if (!deleteAccountEndpoint) { - setError('Account deletion not supported'); - return false; - } - setIsLoading(true); - setError(null); - try { - const { error: fetchError } = await api.safeFetch<{ message: string }>( - deleteAccountEndpoint, - { method: 'DELETE', body: JSON.stringify({ password }) } - ); - if (fetchError) { - setError(fetchError); - return false; - } - setUser(null); - clearSession(); - if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); - onLogout?.(); - return true; - } finally { - setIsLoading(false); - } - }, - [api] - ); - - return ( - - {children} - - ); - } - - function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext); - if (!ctx) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return ctx; - } - - return { AuthProvider, useAuth }; -} diff --git a/vendor/bytelyst/react-auth/src/index.ts b/vendor/bytelyst/react-auth/src/index.ts deleted file mode 100644 index 424c649..0000000 --- a/vendor/bytelyst/react-auth/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createAuthProvider } from './auth-context.js'; -export type { - AuthProviderInfo, - BaseUser, - AuthContextValue, - AuthConfig, - LoginResult, -} from './types.js'; diff --git a/vendor/bytelyst/react-auth/src/types.ts b/vendor/bytelyst/react-auth/src/types.ts deleted file mode 100644 index 029267d..0000000 --- a/vendor/bytelyst/react-auth/src/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -export interface BaseUser { - email: string; - name: string; - role: string; - [key: string]: unknown; -} - -export interface AuthProviderInfo { - provider: string; - email: string; - linkedAt: string; - lastUsedAt: string | null; -} - -export interface AuthContextValue { - user: TUser | null; - isAuthenticated: boolean; - isLoading: boolean; - error: string | null; - success: string | null; - login: (email: string, password: string) => Promise; - register: (email: string, password: string, displayName: string) => Promise; - logout: () => void; - forgotPassword: (email: string) => Promise; - changePassword: (currentPassword: string, newPassword: string) => Promise; - deleteAccount: (password: string) => Promise; - updateUser: (updates: Partial) => void; - clearMessages: () => void; - - // ── SmartAuth: Social login (Phase 1C) ──────────── - loginWithGoogle: (idToken: string) => Promise; - loginWithMicrosoft: (idToken: string) => Promise; - loginWithApple: (idToken: string) => Promise; - - // ── SmartAuth: Provider management (Phase 1C) ───── - providers: AuthProviderInfo[]; - linkProvider: (provider: string, idToken: string) => Promise; - unlinkProvider: (provider: string) => Promise; - refreshProviders: () => Promise; - - // ── SmartAuth: MFA state (Phase 2D) ─────────────── - mfaRequired: boolean; - mfaMethods: string[]; - mfaChallenge: string | null; - verifyMfa: (code: string, method: 'totp' | 'recovery') => Promise; -} - -export interface LoginResult { - user: TUser; - accessToken: string; - refreshToken: string; -} - -export interface AuthConfig { - /** Base URL for auth API calls. Default: '/api'. */ - baseUrl?: string; - /** Product identifier sent with OAuth requests. */ - productId?: string; - storagePrefix: string; - loginEndpoint: string; - registerEndpoint?: string; - forgotPasswordEndpoint?: string; - changePasswordEndpoint?: string; - deleteAccountEndpoint?: string; - refreshEndpoint?: string; - /** Token refresh interval in ms. Default: 45 * 60 * 1000 (45 minutes). */ - refreshIntervalMs?: number; - mapLoginResponse: (data: unknown) => LoginResult; - onLoginFallback?: ( - email: string, - password: string, - error: string - ) => Promise | null>; - /** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */ - onInit?: () => LoginResult | null; - onLogout?: () => void; - - // ── SmartAuth endpoint config (Phase 1C+) ───────── - /** Endpoint for OAuth social login. Default: '/auth/oauth'. Provider appended as path segment. */ - oauthEndpoint?: string; - /** Endpoint for listing providers. Default: '/auth/providers'. */ - providersEndpoint?: string; - /** Endpoint for linking a provider. Default: '/auth/providers/link'. */ - linkProviderEndpoint?: string; - /** Endpoint for MFA verification. Default: '/auth/mfa/verify'. */ - mfaVerifyEndpoint?: string; - /** Callback when MFA is required after login. Receives challenge token and methods. */ - onMfaRequired?: (challenge: string, methods: string[]) => void; -} diff --git a/vendor/bytelyst/react-auth/tsconfig.json b/vendor/bytelyst/react-auth/tsconfig.json deleted file mode 100644 index 4447784..0000000 --- a/vendor/bytelyst/react-auth/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/vendor/bytelyst/react-auth/vitest.config.ts b/vendor/bytelyst/react-auth/vitest.config.ts deleted file mode 100644 index 9eaeb03..0000000 --- a/vendor/bytelyst/react-auth/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - // Use happy-dom to avoid jsdom's heavy dependency chain and ESM/CJS edge cases. - // This package only needs a minimal DOM + localStorage for unit tests. - environment: 'happy-dom', - pool: 'forks', - }, -}); diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 deleted file mode 100644 index 41a9cd1..0000000 Binary files a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/2f014b13 and /dev/null differ diff --git a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c b/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c deleted file mode 100644 index 7cff770..0000000 Binary files a/vendor/bytelyst/react-native-platform-sdk/node-compile-cache/v22.22.0-x64-9de703df-0/b0598a2c and /dev/null differ diff --git a/vendor/bytelyst/react-native-platform-sdk/package.json b/vendor/bytelyst/react-native-platform-sdk/package.json deleted file mode 100644 index a44d670..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/package.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "name": "@bytelyst/react-native-platform-sdk", - "version": "1.0.0", - "description": "React Native SDK for ByteLyst platform services", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./auth": { - "import": "./dist/auth.js", - "types": "./dist/auth.d.ts" - }, - "./telemetry": { - "import": "./dist/telemetry.js", - "types": "./dist/telemetry.d.ts" - }, - "./feature-flags": { - "import": "./dist/feature-flags.js", - "types": "./dist/feature-flags.d.ts" - }, - "./kill-switch": { - "import": "./dist/kill-switch.js", - "types": "./dist/kill-switch.d.ts" - }, - "./broadcasts": { - "import": "./dist/broadcasts.js", - "types": "./dist/broadcasts.d.ts" - }, - "./surveys": { - "import": "./dist/surveys.js", - "types": "./dist/surveys.d.ts" - } - }, - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "lint": "eslint src/**/*.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": {}, - "peerDependencies": { - "react": ">=18.0.0", - "react-native": ">=0.72.0", - "expo": ">=49.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "keywords": [ - "react-native", - "bytelyst", - "platform", - "expo", - "mobile" - ], - "license": "MIT", - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth.ts deleted file mode 100644 index 9ec3a88..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAuth, AuthProvider, type AuthState, type AuthContextType } from './auth/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts deleted file mode 100644 index a5af012..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/auth/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Auth module — React context + hook for authentication in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface AuthState { - isAuthenticated: boolean; - isLoading: boolean; - userId: string | null; - email: string | null; - error: string | null; -} - -export interface AuthContextType extends AuthState { - login: (email: string, password: string) => Promise; - register: (email: string, password: string, displayName: string) => Promise; - loginWithGoogle: (idToken: string) => Promise; - loginWithApple: (idToken: string) => Promise; - logout: () => Promise; - refreshSession: () => Promise; -} - -const AuthContext = createContext(null); - -export function useAuth(): AuthContextType { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); - return ctx; -} - -interface AuthProviderProps { - sdk: PlatformSDK; - children: React.ReactNode; - /** Called when tokens are received — persist to secure storage */ - onTokens?: (access: string, refresh: string) => void; - /** Called on logout — clear secure storage */ - onLogout?: () => void; -} - -export function AuthProvider({ - sdk, - children, - onTokens, - onLogout, -}: AuthProviderProps): React.JSX.Element { - const [state, setState] = useState({ - isAuthenticated: false, - isLoading: true, - userId: null, - email: null, - error: null, - }); - - const handleTokenResponse = useCallback( - async (res: Response) => { - if (!res.ok) { - const body = (await res.json().catch(() => ({ message: 'Login failed' }))) as { - message?: string; - }; - throw new Error(body.message ?? `HTTP ${res.status}`); - } - const data = (await res.json()) as { - accessToken?: string; - refreshToken?: string; - user?: { id?: string; email?: string }; - }; - if (data.accessToken && data.refreshToken) { - onTokens?.(data.accessToken, data.refreshToken); - } - setState({ - isAuthenticated: true, - isLoading: false, - userId: data.user?.id ?? null, - email: data.user?.email ?? null, - error: null, - }); - }, - [onTokens] - ); - - const login = useCallback( - async (email: string, password: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/login', { - method: 'POST', - body: JSON.stringify({ email, password, productId: sdk.config.productId }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const register = useCallback( - async (email: string, password: string, displayName: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/register', { - method: 'POST', - body: JSON.stringify({ - email, - password, - displayName, - productId: sdk.config.productId, - }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Registration failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const loginWithGoogle = useCallback( - async (idToken: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/oauth/google', { - method: 'POST', - body: JSON.stringify({ idToken }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Google login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const loginWithApple = useCallback( - async (idToken: string) => { - setState(s => ({ ...s, isLoading: true, error: null })); - try { - const res = await sdk.fetch('/auth/oauth/apple', { - method: 'POST', - body: JSON.stringify({ idToken }), - }); - await handleTokenResponse(res); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : 'Apple login failed'; - setState(s => ({ ...s, isLoading: false, error: msg })); - } - }, - [sdk, handleTokenResponse] - ); - - const logout = useCallback(async () => { - try { - await sdk.fetch('/auth/logout', { method: 'POST' }); - } catch { - /* best-effort */ - } - onLogout?.(); - setState({ - isAuthenticated: false, - isLoading: false, - userId: null, - email: null, - error: null, - }); - }, [sdk, onLogout]); - - const refreshSession = useCallback(async () => { - setState(s => ({ ...s, isLoading: true })); - try { - const res = await sdk.fetch('/auth/me'); - if (res.ok) { - const data = (await res.json()) as { id?: string; email?: string }; - setState({ - isAuthenticated: true, - isLoading: false, - userId: data.id ?? null, - email: data.email ?? null, - error: null, - }); - } else { - setState(s => ({ ...s, isAuthenticated: false, isLoading: false })); - } - } catch { - setState(s => ({ ...s, isLoading: false })); - } - }, [sdk]); - - useEffect(() => { - refreshSession(); - }, [refreshSession]); - - const value: AuthContextType = { - ...state, - login, - register, - loginWithGoogle, - loginWithApple, - logout, - refreshSession, - }; - - return React.createElement(AuthContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts deleted file mode 100644 index 2dfce29..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - useBroadcasts, - BroadcastProvider, - InAppMessageBanner, - BroadcastModal, - type InAppMessage, -} from './broadcasts/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts deleted file mode 100644 index 3d29d96..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/broadcasts/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Broadcasts module — React context + hook for in-app messages in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface InAppMessage { - id: string; - title: string; - body: string; - type: 'info' | 'warning' | 'critical'; - action?: { label: string; url: string }; - dismissible: boolean; - expiresAt?: string; -} - -interface BroadcastContextType { - messages: InAppMessage[]; - dismiss: (id: string) => void; - refresh: () => Promise; -} - -const BroadcastContext = createContext(null); - -export function useBroadcasts(): BroadcastContextType { - const ctx = useContext(BroadcastContext); - if (!ctx) throw new Error('useBroadcasts must be used within a BroadcastProvider'); - return ctx; -} - -interface BroadcastProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 300000 = 5 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function BroadcastProvider({ - sdk, - pollInterval = 300_000, - children, -}: BroadcastProviderProps): React.JSX.Element { - const [messages, setMessages] = useState([]); - - const refresh = useCallback(async () => { - try { - const res = await sdk.fetch('/broadcasts'); - if (!res.ok) return; - const data = (await res.json()) as { - messages?: Array<{ - id: string; - title: string; - body: string; - ctaText?: string; - ctaUrl?: string; - priority?: 'low' | 'normal' | 'high' | 'urgent'; - dismissible?: boolean; - expiresAt?: string; - }>; - }; - const raw = data.messages ?? []; - const mapped: InAppMessage[] = raw.map(m => ({ - id: m.id, - title: m.title, - body: m.body, - type: - m.priority === 'urgent' ? 'critical' : m.priority === 'high' ? 'warning' : 'info', - action: - m.ctaText && m.ctaUrl ? { label: m.ctaText, url: m.ctaUrl } : undefined, - dismissible: m.dismissible !== false, - expiresAt: m.expiresAt, - })); - setMessages(mapped); - } catch { - /* silent */ - } - }, [sdk]); - - const dismiss = useCallback( - (id: string) => { - setMessages(prev => prev.filter(m => m.id !== id)); - sdk.fetch(`/broadcasts/${id}/dismiss`, { method: 'POST' }).catch(() => {}); - }, - [sdk] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: BroadcastContextType = { messages, dismiss, refresh }; - return React.createElement(BroadcastContext.Provider, { value }, children); -} - -// MARK: - UI Components - -interface InAppMessageBannerProps { - message: InAppMessage; - onDismiss: () => void; -} - -/** - * Placeholder banner component — product apps should implement their own - * styled version using this as a reference. Returns null (render-only hook). - */ -export function InAppMessageBanner(_props: InAppMessageBannerProps): React.JSX.Element | null { - // Product apps implement their own styled component - return null; -} - -interface BroadcastModalProps { - message: InAppMessage | null; - onDismiss: () => void; -} - -/** - * Placeholder modal component — product apps should implement their own - * styled version. Returns null. - */ -export function BroadcastModal(_props: BroadcastModalProps): React.JSX.Element | null { - return null; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/core.ts b/vendor/bytelyst/react-native-platform-sdk/src/core.ts deleted file mode 100644 index b1e3c4f..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/core.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Core SDK factory — creates and configures platform clients for React Native. - */ - -export interface PlatformSDKConfig { - /** Platform-service base URL (e.g. https://api.bytelyst.com) */ - baseURL: string; - /** Product ID (e.g. 'nomgap', 'flowmonk') */ - productId: string; - /** Function that returns the current access token */ - getAccessToken: () => string | null; -} - -export interface PlatformSDK { - config: PlatformSDKConfig; - /** Generic authenticated fetch against platform-service */ - fetch: (path: string, init?: RequestInit) => Promise; -} - -/** - * Create a configured platform SDK instance. - * Pass to providers to wire up auth, telemetry, flags, etc. - */ -export function createRNPlatformSDK(config: PlatformSDKConfig): PlatformSDK { - const platformFetch = async (path: string, init?: RequestInit): Promise => { - const url = `${config.baseURL}${path}`; - const token = config.getAccessToken(); - const headers: Record = { - 'Content-Type': 'application/json', - 'x-product-id': config.productId, - ...((init?.headers as Record) ?? {}), - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - return globalThis.fetch(url, { ...init, headers }); - }; - - return { config, fetch: platformFetch }; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts deleted file mode 100644 index 15f9fc3..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFeatureFlags, FeatureFlagProvider, type FeatureFlag } from './feature-flags/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts deleted file mode 100644 index 77825ac..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/feature-flags/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Feature Flags module — React context + hook for feature flags in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface FeatureFlag { - key: string; - enabled: boolean; - value?: unknown; -} - -interface FeatureFlagContextType { - flags: Map; - isEnabled: (key: string) => boolean; - getValue: (key: string, fallback: T) => T; - refresh: () => Promise; -} - -const FeatureFlagContext = createContext(null); - -export function useFeatureFlags(): FeatureFlagContextType { - const ctx = useContext(FeatureFlagContext); - if (!ctx) throw new Error('useFeatureFlags must be used within a FeatureFlagProvider'); - return ctx; -} - -interface FeatureFlagProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 60000) */ - pollInterval?: number; - /** Optional user id for targeted flag evaluation (GET /flags/poll). */ - userId?: string | null; - children: React.ReactNode; -} - -export function FeatureFlagProvider({ - sdk, - pollInterval = 60_000, - userId, - children, -}: FeatureFlagProviderProps): React.JSX.Element { - const [flags, setFlags] = useState>(new Map()); - - const refresh = useCallback(async () => { - try { - const qs = new URLSearchParams({ platform: 'mobile' }); - if (userId) qs.set('userId', userId); - const res = await sdk.fetch(`/flags/poll?${qs.toString()}`); - if (res.ok) { - const data = (await res.json()) as { flags?: Record }; - const map = new Map(); - const raw = data.flags ?? {}; - for (const [key, enabled] of Object.entries(raw)) { - map.set(key, { key, enabled, value: enabled }); - } - setFlags(map); - } - } catch { - /* fail-open: keep existing flags */ - } - }, [sdk, userId]); - - const isEnabled = useCallback( - (key: string): boolean => { - return flags.get(key)?.enabled ?? false; - }, - [flags] - ); - - const getValue = useCallback( - (key: string, fallback: T): T => { - const flag = flags.get(key); - if (!flag?.enabled) return fallback; - return (flag.value as T) ?? fallback; - }, - [flags] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: FeatureFlagContextType = { flags, isEnabled, getValue, refresh }; - return React.createElement(FeatureFlagContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/index.ts deleted file mode 100644 index 5a5e533..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * ByteLyst React Native Platform SDK - * - * Provides platform services for React Native/Expo apps: - * - Authentication - * - Telemetry - * - Feature Flags - * - Kill Switch - * - Broadcasts & Surveys - */ - -export { createRNPlatformSDK } from './core.js'; - -// Re-exports from sub-modules -export { - useAuth, - AuthProvider, - type AuthState, - type AuthContextType, -} from './auth/index.js'; - -export { - useTelemetry, - TelemetryProvider, - type TelemetryEvent, - type TelemetryConfig, -} from './telemetry/index.js'; - -export { - useFeatureFlags, - FeatureFlagProvider, - type FeatureFlag, -} from './feature-flags/index.js'; - -export { - useKillSwitch, - KillSwitchProvider, - type KillSwitchState, -} from './kill-switch/index.js'; - -export { - useBroadcasts, - BroadcastProvider, - InAppMessageBanner, - BroadcastModal, - type InAppMessage, -} from './broadcasts/index.js'; - -export { - useSurveys, - SurveyProvider, - SurveyModal, - type ActiveSurvey, - type Question, -} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts deleted file mode 100644 index ad836e4..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch.ts +++ /dev/null @@ -1 +0,0 @@ -export { useKillSwitch, KillSwitchProvider, type KillSwitchState } from './kill-switch/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts deleted file mode 100644 index b9f5f16..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/kill-switch/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Kill Switch module — React context + hook for kill switch in React Native apps. - * Fail-open: if the check fails, the app is assumed to be enabled. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface KillSwitchState { - disabled: boolean; - reason?: string; - isLoading: boolean; -} - -interface KillSwitchContextType extends KillSwitchState { - check: () => Promise; -} - -const KillSwitchContext = createContext(null); - -export function useKillSwitch(): KillSwitchContextType { - const ctx = useContext(KillSwitchContext); - if (!ctx) throw new Error('useKillSwitch must be used within a KillSwitchProvider'); - return ctx; -} - -interface KillSwitchProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 300000 = 5 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function KillSwitchProvider({ - sdk, - pollInterval = 300_000, - children, -}: KillSwitchProviderProps): React.JSX.Element { - const [state, setState] = useState({ - disabled: false, - isLoading: true, - }); - - const check = useCallback(async () => { - try { - const res = await sdk.fetch('/settings/kill-switch'); - if (res.ok) { - const data = (await res.json()) as { - disabled?: boolean; - reason?: string; - message?: string; - }; - setState({ - disabled: data.disabled ?? false, - reason: data.reason ?? data.message, - isLoading: false, - }); - } else { - // Fail-open - setState(s => ({ ...s, disabled: false, isLoading: false })); - } - } catch { - // Fail-open on network error - setState(s => ({ ...s, disabled: false, isLoading: false })); - } - }, [sdk]); - - useEffect(() => { - check(); - const id = setInterval(check, pollInterval); - return () => clearInterval(id); - }, [check, pollInterval]); - - const value: KillSwitchContextType = { ...state, check }; - return React.createElement(KillSwitchContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts deleted file mode 100644 index 3d2b7f0..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/surveys.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - useSurveys, - SurveyProvider, - SurveyModal, - type ActiveSurvey, - type Question, -} from './surveys/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts deleted file mode 100644 index f62654b..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/surveys/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Surveys module — React context + hook for in-app surveys in React Native apps. - */ - -import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; -import type { PlatformSDK } from '../core.js'; - -export interface Question { - id: string; - text: string; - type: 'rating' | 'text' | 'choice'; - options?: string[]; - required: boolean; -} - -export interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - expiresAt?: string; -} - -interface SurveyContextType { - activeSurvey: ActiveSurvey | null; - submit: (surveyId: string, answers: Record) => Promise; - dismiss: (surveyId: string) => void; - refresh: () => Promise; -} - -const SurveyContext = createContext(null); - -export function useSurveys(): SurveyContextType { - const ctx = useContext(SurveyContext); - if (!ctx) throw new Error('useSurveys must be used within a SurveyProvider'); - return ctx; -} - -interface SurveyProviderProps { - sdk: PlatformSDK; - /** Poll interval in ms (default: 600000 = 10 min) */ - pollInterval?: number; - children: React.ReactNode; -} - -export function SurveyProvider({ - sdk, - pollInterval = 600_000, - children, -}: SurveyProviderProps): React.JSX.Element { - const [activeSurvey, setActiveSurvey] = useState(null); - - const refresh = useCallback(async () => { - try { - const res = await sdk.fetch('/surveys/active'); - if (!res.ok) return; - const data = (await res.json()) as { survey?: ActiveSurvey | null }; - setActiveSurvey(data.survey ?? null); - } catch { - /* silent */ - } - }, [sdk]); - - const submit = useCallback( - async (surveyId: string, answers: Record) => { - await sdk.fetch(`/surveys/${surveyId}/start`, { method: 'POST' }); - for (const [questionId, answer] of Object.entries(answers)) { - await sdk.fetch(`/surveys/${surveyId}/response`, { - method: 'POST', - body: JSON.stringify({ questionId, answer }), - }); - } - await sdk.fetch(`/surveys/${surveyId}/complete`, { method: 'POST', body: '{}' }); - setActiveSurvey(null); - }, - [sdk] - ); - - const dismiss = useCallback( - (surveyId: string) => { - setActiveSurvey(null); - sdk.fetch(`/surveys/${surveyId}/dismiss`, { method: 'POST' }).catch(() => {}); - }, - [sdk] - ); - - useEffect(() => { - refresh(); - const id = setInterval(refresh, pollInterval); - return () => clearInterval(id); - }, [refresh, pollInterval]); - - const value: SurveyContextType = { activeSurvey, submit, dismiss, refresh }; - return React.createElement(SurveyContext.Provider, { value }, children); -} - -// MARK: - UI Components - -interface SurveyModalProps { - survey: ActiveSurvey | null; - onSubmit: (answers: Record) => void; - onDismiss: () => void; -} - -/** - * Placeholder survey modal — product apps should implement their own - * styled version. Returns null. - */ -export function SurveyModal(_props: SurveyModalProps): React.JSX.Element | null { - return null; -} diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts deleted file mode 100644 index 82a6420..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/telemetry.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - useTelemetry, - TelemetryProvider, - type TelemetryEvent, - type TelemetryConfig, -} from './telemetry/index.js'; diff --git a/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts b/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts deleted file mode 100644 index 3cced99..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/src/telemetry/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Telemetry module — React context + hook for event tracking in React Native apps. - * Maps queued events to platform-service TelemetryEventSchema for POST /telemetry/events. - */ - -import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react'; -import { Platform } from 'react-native'; -import type { PlatformSDK } from '../core.js'; - -export interface TelemetryEvent { - name: string; - properties?: Record; - timestamp?: string; -} - -export interface TelemetryConfig { - /** Flush interval in ms (default: 30000) */ - flushInterval?: number; - /** Max batch size before auto-flush (default: 20) */ - maxBatchSize?: number; - appVersion?: string; - buildNumber?: string; - /** Default: beta */ - releaseChannel?: 'dev' | 'beta' | 'prod'; - /** Stable anonymous id (e.g. from MMKV). If omitted, generated per app session. */ - getInstallId?: () => string; -} - -function randomUuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -interface TelemetryContextType { - track: (name: string, properties?: Record) => void; - flush: () => Promise; -} - -const TelemetryContext = createContext(null); - -export function useTelemetry(): TelemetryContextType { - const ctx = useContext(TelemetryContext); - if (!ctx) throw new Error('useTelemetry must be used within a TelemetryProvider'); - return ctx; -} - -interface TelemetryProviderProps { - sdk: PlatformSDK; - config?: TelemetryConfig; - children: React.ReactNode; -} - -export function TelemetryProvider({ - sdk, - config, - children, -}: TelemetryProviderProps): React.JSX.Element { - const queue = useRef([]); - const sessionIdRef = useRef(randomUuid()); - const installIdRef = useRef(null); - const flushInterval = config?.flushInterval ?? 30_000; - const maxBatchSize = config?.maxBatchSize ?? 20; - - const resolveInstallId = useCallback((): string => { - if (!installIdRef.current) { - installIdRef.current = config?.getInstallId?.() ?? randomUuid(); - } - return installIdRef.current; - }, [config?.getInstallId]); - - const flush = useCallback(async () => { - if (queue.current.length === 0) return; - const batch = queue.current.splice(0); - const os = Platform.OS; - const platform = os === 'ios' ? 'ios' : os === 'android' ? 'android' : 'web'; - const osFamily = platform === 'ios' ? 'ios' : platform === 'android' ? 'android' : 'other'; - const events = batch.map(e => ({ - id: randomUuid(), - productId: sdk.config.productId, - anonymousInstallId: resolveInstallId(), - sessionId: sessionIdRef.current, - platform, - channel: 'mobile_app' as const, - osFamily, - osVersion: String(Platform.Version ?? ''), - appVersion: config?.appVersion ?? '0.0.0', - buildNumber: config?.buildNumber ?? '0', - releaseChannel: config?.releaseChannel ?? ('beta' as const), - eventType: 'info' as const, - module: 'app', - eventName: e.name, - occurredAt: e.timestamp ?? new Date().toISOString(), - context: e.properties, - })); - try { - await sdk.fetch('/telemetry/events', { - method: 'POST', - body: JSON.stringify({ productId: sdk.config.productId, events }), - }); - } catch { - queue.current.unshift(...batch); - } - }, [sdk, config?.appVersion, config?.buildNumber, config?.releaseChannel, resolveInstallId]); - - const track = useCallback( - (name: string, properties?: Record) => { - queue.current.push({ - name, - properties, - timestamp: new Date().toISOString(), - }); - if (queue.current.length >= maxBatchSize) { - flush(); - } - }, - [maxBatchSize, flush] - ); - - useEffect(() => { - const id = setInterval(flush, flushInterval); - return () => { - clearInterval(id); - flush(); - }; - }, [flush, flushInterval]); - - const value: TelemetryContextType = { track, flush }; - return React.createElement(TelemetryContext.Provider, { value }, children); -} diff --git a/vendor/bytelyst/react-native-platform-sdk/tsconfig.json b/vendor/bytelyst/react-native-platform-sdk/tsconfig.json deleted file mode 100644 index 055a6d0..0000000 --- a/vendor/bytelyst/react-native-platform-sdk/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022"], - "jsx": "react-jsx", - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/referral-client/package.json b/vendor/bytelyst/referral-client/package.json deleted file mode 100644 index b922a34..0000000 --- a/vendor/bytelyst/referral-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/referral-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/referral-client/src/client.test.ts b/vendor/bytelyst/referral-client/src/client.test.ts deleted file mode 100644 index 24eeafe..0000000 --- a/vendor/bytelyst/referral-client/src/client.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createReferralClient } from './client.js'; -import type { ReferralDoc } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - getAccessToken: () => 'test-token', -}; - -function mockReferral(overrides?: Partial): ReferralDoc { - return { - id: 'ref-1', - productId: 'testapp', - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredUserId: null, - referredEmail: 'bob@test.com', - status: 'pending', - referrerRewardTokens: 1000, - referredRewardTokens: 500, - referrerRewarded: false, - referredRewarded: false, - createdAt: '2026-01-01T00:00:00Z', - completedAt: null, - ...overrides, - }; -} - -describe('createReferralClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should create a referral', async () => { - const ref = mockReferral(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ref), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.createReferral({ - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredEmail: 'bob@test.com', - }); - - expect(result).toEqual(ref); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals', - expect.objectContaining({ method: 'POST' }) - ); - }); - - it('should list referrals by referrerId', async () => { - const data = { referrals: [mockReferral()], count: 1 }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(data), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.listMyReferrals('user-1'); - - expect(result.count).toBe(1); - expect(result.referrals).toHaveLength(1); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals/by-referrer/user-1', - expect.any(Object) - ); - }); - - it('should get referral stats', async () => { - const stats = { total: 10, completed: 5, rewarded: 3 }; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(stats), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getReferralStats(); - expect(result).toEqual(stats); - }); - - it('should update referral status via PUT', async () => { - const updated = mockReferral({ status: 'signed_up' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(updated), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.updateReferralStatus('ref-1', 'user-1', 'signed_up'); - - expect(result.status).toBe('signed_up'); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/referrals/ref-1', - expect.objectContaining({ method: 'PUT' }) - ); - }); - - it('should get referral by email', async () => { - const ref = mockReferral(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(ref), - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getByEmail('bob@test.com'); - expect(result).toEqual(ref); - }); - - it('should return null for 404 on getByEmail', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 404, - }) - ); - - const client = createReferralClient(baseConfig); - const result = await client.getByEmail('unknown@test.com'); - expect(result).toBeNull(); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ total: 0, completed: 0, rewarded: 0 }), - }) - ); - - const client = createReferralClient(baseConfig); - await client.getReferralStats(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - expect(callHeaders['x-request-id']).toBeDefined(); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const client = createReferralClient(baseConfig); - await expect(client.getReferralStats()).rejects.toThrow('getReferralStats failed: 500'); - }); - - it('should build share link', () => { - const client = createReferralClient(baseConfig); - const link = client.buildShareLink('ABC123'); - expect(link).toContain('refer/ABC123'); - expect(link).toContain('product=testapp'); - }); - - it('should build share message', () => { - const client = createReferralClient(baseConfig); - const msg = client.buildShareMessage('ABC123', 'NomGap'); - expect(msg).toContain('NomGap'); - expect(msg).toContain('refer/ABC123'); - }); - - it('should calculate earned days', () => { - const client = createReferralClient(baseConfig); - expect(client.calculateEarnedDays(3)).toBe(21); - expect(client.calculateEarnedDays(0)).toBe(0); - expect(client.calculateEarnedDays(2, 14)).toBe(28); - }); - - it('should use custom default reward tokens', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockReferral()), - }) - ); - - const client = createReferralClient({ - ...baseConfig, - defaultRewardTokens: { referrer: 2000, referred: 1000 }, - }); - await client.createReferral({ - referrerId: 'user-1', - referrerEmail: 'alice@test.com', - referredEmail: 'bob@test.com', - }); - - const fetchMock = globalThis.fetch as ReturnType; - const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); - expect(body.referrerRewardTokens).toBe(2000); - expect(body.referredRewardTokens).toBe(1000); - }); -}); diff --git a/vendor/bytelyst/referral-client/src/client.ts b/vendor/bytelyst/referral-client/src/client.ts deleted file mode 100644 index 3dac261..0000000 --- a/vendor/bytelyst/referral-client/src/client.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Browser/React Native-safe referral client for platform-service. - * - * Wraps platform-service /referrals/* endpoints. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { ReferralClient, ReferralClientConfig, ReferralDoc } from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createReferralClient(config: ReferralClientConfig): ReferralClient { - const { baseUrl, productId, getAccessToken, defaultRewardTokens } = config; - - const defaultReferrerTokens = defaultRewardTokens?.referrer ?? 1000; - const defaultReferredTokens = defaultRewardTokens?.referred ?? 500; - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - async function listMyReferrals( - referrerId: string - ): Promise<{ referrals: ReferralDoc[]; count: number }> { - const res = await globalThis.fetch( - `${baseUrl}/referrals/by-referrer/${encodeURIComponent(referrerId)}`, - { headers: headers() } - ); - if (!res.ok) throw new Error(`listMyReferrals failed: ${res.status}`); - const data = (await res.json()) as { referrals: ReferralDoc[]; count: number }; - return data; - } - - async function getReferralStats(): Promise<{ - total: number; - completed: number; - rewarded: number; - }> { - const res = await globalThis.fetch(`${baseUrl}/referrals/stats`, { headers: headers() }); - if (!res.ok) throw new Error(`getReferralStats failed: ${res.status}`); - return (await res.json()) as { total: number; completed: number; rewarded: number }; - } - - async function createReferral(input: { - referrerId: string; - referrerEmail: string; - referredEmail: string; - }): Promise { - const body = { - ...input, - productId, - referrerRewardTokens: defaultReferrerTokens, - referredRewardTokens: defaultReferredTokens, - }; - const res = await globalThis.fetch(`${baseUrl}/referrals`, { - method: 'POST', - headers: headers(), - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error(`createReferral failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - async function updateReferralStatus( - id: string, - referrerId: string, - status: ReferralDoc['status'] - ): Promise { - const res = await globalThis.fetch(`${baseUrl}/referrals/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify({ referrerId, status }), - }); - if (!res.ok) throw new Error(`updateReferralStatus failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - async function getByEmail(email: string): Promise { - const res = await globalThis.fetch( - `${baseUrl}/referrals/by-email/${encodeURIComponent(email)}`, - { headers: headers() } - ); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`getByEmail failed: ${res.status}`); - return (await res.json()) as ReferralDoc; - } - - function buildShareLink(code: string): string { - return `https://bytelyst.com/refer/${encodeURIComponent(code)}?product=${encodeURIComponent(productId)}`; - } - - function buildShareMessage(code: string, productName: string): string { - const link = buildShareLink(code); - return `Try ${productName}! Use my referral link to get started: ${link}`; - } - - function calculateEarnedDays(conversions: number, daysPerReferral = 7): number { - return Math.max(0, conversions * daysPerReferral); - } - - return { - listMyReferrals, - getReferralStats, - createReferral, - updateReferralStatus, - getByEmail, - buildShareLink, - buildShareMessage, - calculateEarnedDays, - }; -} diff --git a/vendor/bytelyst/referral-client/src/index.ts b/vendor/bytelyst/referral-client/src/index.ts deleted file mode 100644 index d021d6e..0000000 --- a/vendor/bytelyst/referral-client/src/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -export interface ReferralClientOptions { - baseUrl: string; - productId: string; - getAccessToken: () => string; -} - -export interface ReferralStats { - totalReferrals: number; - successfulReferrals: number; - pendingReferrals: number; - rewardsEarned: number; - referralCode: string; -} - -export interface ReferralInfo { - code: string; - referrerEmail: string; - status: string; -} - -function joinUrl(base: string, path: string): string { - const b = base.replace(/\/$/, ""); - const p = path.startsWith("/") ? path : `/${path}`; - return `${b}${p}`; -} - -function headers(opts: ReferralClientOptions): HeadersInit { - return { - Authorization: `Bearer ${opts.getAccessToken()}`, - "X-Product-Id": opts.productId, - Accept: "application/json", - }; -} - -async function parseJson(res: Response): Promise { - if (!res.ok) { - const text = await res.text(); - throw new Error(`HTTP ${res.status}: ${text || res.statusText}`); - } - return res.json() as Promise; -} - -export function createReferralClient(opts: ReferralClientOptions) { - const { baseUrl } = opts; - - return { - async getReferralStats(): Promise { - const res = await fetch(joinUrl(baseUrl, "/referrals/stats"), { - method: "GET", - headers: headers(opts), - }); - return parseJson(res); - }, - - buildShareLink(code: string): string { - const b = baseUrl.replace(/\/$/, ""); - return `${b}/r/${code}`; - }, - - buildShareMessage(code: string, productName: string): string { - return `Try ${productName}! Use my referral code: ${code}`; - }, - - async getByEmail(code: string): Promise { - const res = await fetch( - joinUrl(baseUrl, `/referrals/${encodeURIComponent(code)}`), - { method: "GET", headers: headers(opts) } - ); - return parseJson(res); - }, - }; -} diff --git a/vendor/bytelyst/referral-client/src/types.ts b/vendor/bytelyst/referral-client/src/types.ts deleted file mode 100644 index 5067449..0000000 --- a/vendor/bytelyst/referral-client/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Types for @bytelyst/referral-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface ReferralDoc { - id: string; - productId: string; - referrerId: string; - referrerEmail: string; - referredUserId: string | null; - referredEmail: string; - status: 'pending' | 'signed_up' | 'subscribed' | 'rewarded'; - referrerRewardTokens: number; - referredRewardTokens: number; - referrerRewarded: boolean; - referredRewarded: boolean; - createdAt: string; - completedAt: string | null; -} - -export interface ReferralClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; - - /** Default reward tokens applied when creating referrals. */ - defaultRewardTokens?: { referrer: number; referred: number }; -} - -export interface ReferralClient { - listMyReferrals(referrerId: string): Promise<{ referrals: ReferralDoc[]; count: number }>; - getReferralStats(): Promise<{ total: number; completed: number; rewarded: number }>; - createReferral(input: { - referrerId: string; - referrerEmail: string; - referredEmail: string; - }): Promise; - updateReferralStatus( - id: string, - referrerId: string, - status: ReferralDoc['status'] - ): Promise; - getByEmail(email: string): Promise; - - // Client-side helpers (pure TS, no network) - buildShareLink(code: string): string; - buildShareMessage(code: string, productName: string): string; - calculateEarnedDays(conversions: number, daysPerReferral?: number): number; -} diff --git a/vendor/bytelyst/referral-client/tsconfig.json b/vendor/bytelyst/referral-client/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/referral-client/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/secure-storage-web/package.json b/vendor/bytelyst/secure-storage-web/package.json deleted file mode 100644 index 39a42bd..0000000 --- a/vendor/bytelyst/secure-storage-web/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/secure-storage-web", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "vitest": "^3.0.0", - "fake-indexeddb": "^6.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/secure-storage-web/src/index.ts b/vendor/bytelyst/secure-storage-web/src/index.ts deleted file mode 100644 index 15919c3..0000000 --- a/vendor/bytelyst/secure-storage-web/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @bytelyst/secure-storage-web - * - * Encrypted key-value storage for web applications. - * Uses IndexedDB + Web Crypto (non-extractable AES-256-GCM key). - * Falls back to localStorage when SubtleCrypto is unavailable. - * - * @example - * ```typescript - * import { SecureStorage } from '@bytelyst/secure-storage-web'; - * - * const storage = new SecureStorage('com.myapp'); - * await storage.set('auth_token', 'eyJhbGci...'); - * const token = await storage.get('auth_token'); - * await storage.delete('auth_token'); - * ``` - */ - -export { SecureStorage } from './secure-storage.js'; diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts deleted file mode 100644 index 99e2c80..0000000 --- a/vendor/bytelyst/secure-storage-web/src/secure-storage.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import 'fake-indexeddb/auto'; -import { SecureStorage } from './secure-storage.js'; - -describe('SecureStorage', () => { - let storage: SecureStorage; - - beforeEach(async () => { - storage = new SecureStorage('com.test.app'); - await storage.clear(); - }); - - // ── Basic CRUD ────────────────────────────────── - - it('set and get', async () => { - await storage.set('token', 'abc123'); - const val = await storage.get('token'); - expect(val).toBe('abc123'); - }); - - it('get returns null for missing key', async () => { - const val = await storage.get('nonexistent'); - expect(val).toBeNull(); - }); - - it('overwrite existing key', async () => { - await storage.set('token', 'first'); - await storage.set('token', 'second'); - const val = await storage.get('token'); - expect(val).toBe('second'); - }); - - it('delete removes value', async () => { - await storage.set('token', 'abc'); - await storage.delete('token'); - const val = await storage.get('token'); - expect(val).toBeNull(); - }); - - it('delete non-existent key does not throw', async () => { - await expect(storage.delete('nope')).resolves.toBeUndefined(); - }); - - // ── Clear ─────────────────────────────────────── - - it('clear removes all values', async () => { - await storage.set('a', '1'); - await storage.set('b', '2'); - await storage.clear(); - expect(await storage.get('a')).toBeNull(); - expect(await storage.get('b')).toBeNull(); - }); - - // ── Has ───────────────────────────────────────── - - it('has returns true for existing key', async () => { - await storage.set('x', 'val'); - expect(await storage.has('x')).toBe(true); - }); - - it('has returns false for missing key', async () => { - expect(await storage.has('missing')).toBe(false); - }); - - // ── Keys ──────────────────────────────────────── - - it('keys lists stored keys', async () => { - await storage.set('alpha', '1'); - await storage.set('beta', '2'); - const keys = await storage.keys(); - expect(keys.sort()).toEqual(['alpha', 'beta']); - }); - - it('keys returns empty for fresh storage', async () => { - const keys = await storage.keys(); - expect(keys).toEqual([]); - }); - - // ── Namespace isolation ───────────────────────── - - it('different namespaces are isolated', async () => { - const s1 = new SecureStorage('ns1'); - const s2 = new SecureStorage('ns2'); - await s1.set('key', 'from-s1'); - await s2.set('key', 'from-s2'); - expect(await s1.get('key')).toBe('from-s1'); - expect(await s2.get('key')).toBe('from-s2'); - }); - - // ── Data types ────────────────────────────────── - - it('empty string', async () => { - await storage.set('empty', ''); - const val = await storage.get('empty'); - expect(val).toBe(''); - }); - - it('unicode values', async () => { - const text = 'こんにちは世界 🌍 مرحبا Ñoño'; - await storage.set('unicode', text); - expect(await storage.get('unicode')).toBe(text); - }); - - it('large value', async () => { - const big = 'X'.repeat(100_000); - await storage.set('big', big); - expect(await storage.get('big')).toBe(big); - }); - - it('JSON string value', async () => { - const json = JSON.stringify({ token: 'abc', user: { id: 1 } }); - await storage.set('session', json); - const parsed = JSON.parse((await storage.get('session'))!); - expect(parsed.token).toBe('abc'); - expect(parsed.user.id).toBe(1); - }); - - // ── isSupported ───────────────────────────────── - - it('isSupported returns true in test environment', () => { - expect(SecureStorage.isSupported()).toBe(true); - }); -}); diff --git a/vendor/bytelyst/secure-storage-web/src/secure-storage.ts b/vendor/bytelyst/secure-storage-web/src/secure-storage.ts deleted file mode 100644 index 99832ac..0000000 --- a/vendor/bytelyst/secure-storage-web/src/secure-storage.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * @bytelyst/secure-storage-web — Encrypted web storage - * - * IndexedDB-backed key-value storage with a non-extractable AES-256-GCM - * CryptoKey managed by Web Crypto. The encryption key never leaves the - * browser's crypto subsystem. - * - * Falls back to localStorage if IndexedDB or SubtleCrypto is unavailable. - */ - -const DB_NAME = 'bytelyst_secure_storage'; -const DB_VERSION = 1; -const KEY_STORE = 'crypto_keys'; -const DATA_STORE = 'encrypted_data'; -const MASTER_KEY_ID = '__master_key__'; -const ALGORITHM = 'AES-GCM'; -const KEY_SIZE = 256; -const IV_BYTES = 12; -const TAG_BITS = 128; - -// ── IndexedDB helpers ─────────────────────────────── - -function openDb(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(KEY_STORE)) { - db.createObjectStore(KEY_STORE); - } - if (!db.objectStoreNames.contains(DATA_STORE)) { - db.createObjectStore(DATA_STORE); - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -function idbGet(db: IDBDatabase, store: string, key: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'); - const req = tx.objectStore(store).get(key); - req.onsuccess = () => resolve(req.result as T | undefined); - req.onerror = () => reject(req.error); - }); -} - -function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).put(value, key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbDelete(db: IDBDatabase, store: string, key: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).delete(key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbClear(db: IDBDatabase, store: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readwrite'); - const req = tx.objectStore(store).clear(); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); -} - -function idbGetAllKeys(db: IDBDatabase, store: string): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(store, 'readonly'); - const req = tx.objectStore(store).getAllKeys(); - req.onsuccess = () => resolve(req.result as string[]); - req.onerror = () => reject(req.error); - }); -} - -// ── Stored encrypted value format ─────────────────── - -interface StoredValue { - /** Ciphertext bytes. */ - ct: ArrayBuffer; - /** Initialization vector. */ - iv: ArrayBuffer; -} - -// ── SecureStorage class ───────────────────────────── - -/** - * Encrypted key-value storage backed by IndexedDB + Web Crypto. - * - * The AES-256-GCM master key is generated once and stored as a - * **non-extractable** CryptoKey in IndexedDB. Values are encrypted - * before storage and decrypted on retrieval. The raw key material - * never leaves the browser's crypto subsystem. - * - * Falls back to plaintext localStorage if IndexedDB or SubtleCrypto - * is not available (e.g., legacy browsers, server-side rendering). - * - * @example - * ```typescript - * const storage = new SecureStorage('com.myapp'); - * await storage.set('auth_token', 'eyJhbGci...'); - * const token = await storage.get('auth_token'); - * await storage.delete('auth_token'); - * await storage.clear(); - * ``` - */ -export class SecureStorage { - private readonly namespace: string; - private db: IDBDatabase | null = null; - private masterKey: CryptoKey | null = null; - private readonly fallback: boolean; - - constructor(namespace: string) { - this.namespace = namespace; - this.fallback = !SecureStorage.isSupported(); - } - - /** Check if IndexedDB + SubtleCrypto are available. */ - static isSupported(): boolean { - return ( - typeof indexedDB !== 'undefined' && - typeof globalThis !== 'undefined' && - !!globalThis.crypto?.subtle - ); - } - - // ── Public API ────────────────────────────────── - - /** Store an encrypted value. */ - async set(key: string, value: string): Promise { - if (this.fallback) { - localStorage.setItem(this.ns(key), value); - return; - } - const { db, masterKey } = await this.ensureReady(); - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); - const encoded = new TextEncoder().encode(value); - - const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv: iv.buffer as ArrayBuffer, tagLength: TAG_BITS }, - masterKey, - encoded.buffer as ArrayBuffer - ); - - const stored: StoredValue = { ct: ciphertext, iv: iv.buffer as ArrayBuffer }; - await idbPut(db, DATA_STORE, this.ns(key), stored); - } - - /** Retrieve and decrypt a value. Returns `null` if not found. */ - async get(key: string): Promise { - if (this.fallback) { - return localStorage.getItem(this.ns(key)); - } - const { db, masterKey } = await this.ensureReady(); - const stored = await idbGet(db, DATA_STORE, this.ns(key)); - if (!stored) return null; - - const plaintext = await crypto.subtle.decrypt( - { name: ALGORITHM, iv: stored.iv, tagLength: TAG_BITS }, - masterKey, - stored.ct - ); - - return new TextDecoder().decode(plaintext); - } - - /** Delete a stored value. */ - async delete(key: string): Promise { - if (this.fallback) { - localStorage.removeItem(this.ns(key)); - return; - } - const { db } = await this.ensureReady(); - await idbDelete(db, DATA_STORE, this.ns(key)); - } - - /** Clear all stored values (keeps the master key). */ - async clear(): Promise { - if (this.fallback) { - const prefix = this.ns(''); - const toRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k && k.startsWith(prefix)) toRemove.push(k); - } - toRemove.forEach(k => localStorage.removeItem(k)); - return; - } - const { db } = await this.ensureReady(); - await idbClear(db, DATA_STORE); - } - - /** Check if a key exists. */ - async has(key: string): Promise { - if (this.fallback) { - return localStorage.getItem(this.ns(key)) !== null; - } - const { db } = await this.ensureReady(); - const stored = await idbGet(db, DATA_STORE, this.ns(key)); - return stored !== undefined; - } - - /** List all stored keys (without namespace prefix). */ - async keys(): Promise { - if (this.fallback) { - const prefix = this.ns(''); - const result: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k && k.startsWith(prefix)) result.push(k.slice(prefix.length)); - } - return result; - } - const { db } = await this.ensureReady(); - const allKeys = await idbGetAllKeys(db, DATA_STORE); - const prefix = this.ns(''); - return allKeys.filter(k => k.startsWith(prefix)).map(k => k.slice(prefix.length)); - } - - // ── Internal ──────────────────────────────────── - - private ns(key: string): string { - return `${this.namespace}:${key}`; - } - - private async ensureReady(): Promise<{ db: IDBDatabase; masterKey: CryptoKey }> { - if (this.db && this.masterKey) { - return { db: this.db, masterKey: this.masterKey }; - } - - const db = await openDb(); - this.db = db; - - // Try to load existing master key - let masterKey = await idbGet(db, KEY_STORE, MASTER_KEY_ID); - - if (!masterKey) { - // Generate a new non-extractable master key - masterKey = await crypto.subtle.generateKey( - { name: ALGORITHM, length: KEY_SIZE }, - false, // non-extractable — key material never leaves crypto subsystem - ['encrypt', 'decrypt'] - ); - await idbPut(db, KEY_STORE, MASTER_KEY_ID, masterKey); - } - - this.masterKey = masterKey; - return { db, masterKey }; - } -} diff --git a/vendor/bytelyst/secure-storage-web/tsconfig.json b/vendor/bytelyst/secure-storage-web/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/secure-storage-web/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/speech/package.json b/vendor/bytelyst/speech/package.json deleted file mode 100644 index f4e405e..0000000 --- a/vendor/bytelyst/speech/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@bytelyst/speech", - "version": "0.1.5", - "description": "Cloud-agnostic speech-to-text abstraction for the ByteLyst ecosystem", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "peerDependencies": {}, - "devDependencies": { - "typescript": "^5.7.0", - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/speech/src/__tests__/speech.test.ts b/vendor/bytelyst/speech/src/__tests__/speech.test.ts deleted file mode 100644 index b3b8569..0000000 --- a/vendor/bytelyst/speech/src/__tests__/speech.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createSpeechTranscriber, MockSpeechTranscriber } from '../index.js'; -import type { SpeechTranscriber } from '../index.js'; - -describe('@bytelyst/speech', () => { - describe('MockSpeechTranscriber', () => { - it('implements SpeechTranscriber interface', () => { - const transcriber: SpeechTranscriber = new MockSpeechTranscriber(); - expect(transcriber.isActive).toBe(false); - }); - - it('start/stop lifecycle works', () => { - const transcriber = new MockSpeechTranscriber(); - expect(transcriber.isActive).toBe(false); - - transcriber.start(); - expect(transcriber.isActive).toBe(true); - - const result = transcriber.stop(); - expect(transcriber.isActive).toBe(false); - expect(result).toBe('Hello, this is a mock transcript.'); - }); - - it('returns configurable mock transcript', () => { - const transcriber = new MockSpeechTranscriber(); - transcriber.mockTranscript = 'Custom transcript'; - transcriber.mockConfidence = 0.8; - - transcriber.start(); - const result = transcriber.stop(); - expect(result).toBe('Custom transcript'); - }); - - it('calls onFinal callback on stop', () => { - const transcriber = new MockSpeechTranscriber(); - let receivedText = ''; - let receivedConfidence = 0; - - transcriber.onFinal((text, confidence) => { - receivedText = text; - receivedConfidence = confidence; - }); - - transcriber.start(); - transcriber.stop(); - - expect(receivedText).toBe('Hello, this is a mock transcript.'); - expect(receivedConfidence).toBe(0.95); - }); - - it('calls onPartial callback on pushAudio', () => { - const transcriber = new MockSpeechTranscriber(); - let partialText = ''; - - transcriber.onPartial(text => { - partialText = text; - }); - - transcriber.start(); - transcriber.pushAudio(new Uint8Array(100)); - expect(partialText).toBe('[Recording...]'); - }); - - it('ignores pushAudio when not active', () => { - const transcriber = new MockSpeechTranscriber(); - let called = false; - transcriber.onPartial(() => { - called = true; - }); - - transcriber.pushAudio(new Uint8Array(100)); - expect(called).toBe(false); - }); - - it('setVocabulary stores phrases', () => { - const transcriber = new MockSpeechTranscriber(); - transcriber.setVocabulary(['hello', 'world']); - expect(transcriber.getVocabulary()).toEqual(['hello', 'world']); - }); - - it('simulateError calls onError callback', () => { - const transcriber = new MockSpeechTranscriber(); - let errorMsg = ''; - transcriber.onError(err => { - errorMsg = err.message; - }); - - transcriber.simulateError('connection lost'); - expect(errorMsg).toBe('connection lost'); - }); - }); - - describe('createSpeechTranscriber', () => { - it('creates mock transcriber by default', () => { - const transcriber = createSpeechTranscriber({ provider: 'mock' }); - expect(transcriber).toBeInstanceOf(MockSpeechTranscriber); - }); - - it('throws for azure provider (requires native implementation)', () => { - expect(() => createSpeechTranscriber({ provider: 'azure' })).toThrow( - 'platform-specific implementation' - ); - }); - - it('throws for whisper provider (requires native implementation)', () => { - expect(() => createSpeechTranscriber({ provider: 'whisper' })).toThrow( - 'platform-specific implementation' - ); - }); - - it('throws for unknown provider', () => { - expect(() => createSpeechTranscriber({ provider: 'nonexistent' as 'azure' })).toThrow( - 'Unknown speech provider' - ); - }); - }); -}); diff --git a/vendor/bytelyst/speech/src/factory.ts b/vendor/bytelyst/speech/src/factory.ts deleted file mode 100644 index 04dcbe4..0000000 --- a/vendor/bytelyst/speech/src/factory.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Factory function for creating speech transcribers. - * - * Auto-detects provider from SPEECH_PROVIDER env var, or falls back - * to 'azure' if AZURE_SPEECH_KEY is set, else 'mock'. - */ - -import type { SpeechConfig, SpeechTranscriber } from './types.js'; -import { MockSpeechTranscriber } from './providers/mock.js'; - -/** - * Create a speech transcriber based on config or env vars. - * - * For Azure and Whisper providers, consumers must provide their own - * platform-specific implementations (Python azure_stt.py / whisper_stt.py, - * Swift AzureSpeechTranscriber, etc.). This factory handles the mock - * provider for testing and serves as the registry point for future - * TS-native providers. - */ -export function createSpeechTranscriber(config?: Partial): SpeechTranscriber { - const provider = config?.provider ?? detectProvider(); - - switch (provider) { - case 'mock': - return new MockSpeechTranscriber(); - case 'azure': - case 'whisper': - case 'google': - case 'deepgram': - throw new Error( - `Speech provider '${provider}' requires a platform-specific implementation. ` + - `Use the Python SpeechTranscriber ABC (src/audio/speech_types.py) or ` + - `Swift SpeechTranscriberProtocol for native providers.` - ); - default: - throw new Error(`Unknown speech provider: ${provider}`); - } -} - -function detectProvider(): SpeechConfig['provider'] { - const explicit = (process.env.SPEECH_PROVIDER || '').toLowerCase(); - if ( - explicit === 'azure' || - explicit === 'whisper' || - explicit === 'google' || - explicit === 'deepgram' || - explicit === 'mock' - ) { - return explicit; - } - return 'mock'; -} - -export { MockSpeechTranscriber } from './providers/mock.js'; diff --git a/vendor/bytelyst/speech/src/index.ts b/vendor/bytelyst/speech/src/index.ts deleted file mode 100644 index dd38008..0000000 --- a/vendor/bytelyst/speech/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type { - SpeechTranscriber, - SpeechConfig, - TranscriptionResult, - PartialCallback, - FinalCallback, - ErrorCallback, -} from './types.js'; - -export { createSpeechTranscriber, MockSpeechTranscriber } from './factory.js'; diff --git a/vendor/bytelyst/speech/src/providers/mock.ts b/vendor/bytelyst/speech/src/providers/mock.ts deleted file mode 100644 index a730f88..0000000 --- a/vendor/bytelyst/speech/src/providers/mock.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Mock speech transcriber for testing. - * - * Returns configurable responses without requiring any speech SDK. - */ - -import type { ErrorCallback, FinalCallback, PartialCallback, SpeechTranscriber } from '../types.js'; - -export class MockSpeechTranscriber implements SpeechTranscriber { - private _isActive = false; - private _partialCb: PartialCallback | null = null; - private _finalCb: FinalCallback | null = null; - private _errorCb: ErrorCallback | null = null; - private _vocabulary: string[] = []; - - /** Configurable mock response. */ - public mockTranscript = 'Hello, this is a mock transcript.'; - public mockConfidence = 0.95; - - get isActive(): boolean { - return this._isActive; - } - - start(): void { - this._isActive = true; - } - - stop(): string { - this._isActive = false; - if (this._finalCb) { - this._finalCb(this.mockTranscript, this.mockConfidence); - } - return this.mockTranscript; - } - - pushAudio(_data: ArrayBuffer | Uint8Array): void { - if (!this._isActive) return; - if (this._partialCb) { - this._partialCb('[Recording...]'); - } - } - - onPartial(callback: PartialCallback): void { - this._partialCb = callback; - } - - onFinal(callback: FinalCallback): void { - this._finalCb = callback; - } - - onError(callback: ErrorCallback): void { - this._errorCb = callback; - } - - setVocabulary(phrases: string[]): void { - this._vocabulary = phrases; - } - - /** Simulate an error (for testing). */ - simulateError(message: string): void { - if (this._errorCb) { - this._errorCb(new Error(message)); - } - } - - /** Get the configured vocabulary (for test assertions). */ - getVocabulary(): string[] { - return this._vocabulary; - } -} diff --git a/vendor/bytelyst/speech/src/types.ts b/vendor/bytelyst/speech/src/types.ts deleted file mode 100644 index 4dd72b6..0000000 --- a/vendor/bytelyst/speech/src/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Cloud-agnostic speech-to-text abstraction. - * - * Providers implement SpeechTranscriber to wrap platform-specific SDKs - * (Azure Speech, Google Cloud Speech, Deepgram, local Whisper, etc.) - * behind a unified push-audio + callback interface. - */ - -/** Callback for partial (interim) transcription results. */ -export type PartialCallback = (text: string) => void; - -/** Callback for final (committed) transcription results. */ -export type FinalCallback = (text: string, confidence: number) => void; - -/** Callback for transcription errors. */ -export type ErrorCallback = (error: Error) => void; - -/** - * Cloud-agnostic streaming speech-to-text interface. - * - * Audio is pushed in chunks (PCM 16-bit, 16kHz, mono by convention). - * Results are delivered asynchronously via callbacks. - */ -export interface SpeechTranscriber { - /** Start continuous recognition for the given language. */ - start(language?: string): Promise | void; - - /** Stop recognition and finalize any pending results. */ - stop(): Promise | string; - - /** Push raw audio data (PCM 16-bit, 16kHz, mono). */ - pushAudio(data: ArrayBuffer | Uint8Array): void; - - /** Register callback for partial (interim) results. */ - onPartial(callback: PartialCallback): void; - - /** Register callback for final (committed) results. */ - onFinal(callback: FinalCallback): void; - - /** Register callback for errors. */ - onError(callback: ErrorCallback): void; - - /** Set custom vocabulary / phrase hints for better accuracy. */ - setVocabulary?(phrases: string[]): void; - - /** Whether the transcriber is currently active. */ - readonly isActive: boolean; -} - -/** Configuration for creating a speech transcriber. */ -export interface SpeechConfig { - /** Provider type. */ - provider: 'azure' | 'whisper' | 'google' | 'deepgram' | 'mock'; - - /** Azure-specific: speech service key. */ - speechKey?: string; - - /** Azure-specific: speech service region. */ - speechRegion?: string; - - /** Whisper-specific: model size (tiny, base, small, medium, large). */ - whisperModelSize?: string; - - /** Default language (BCP-47 code, e.g. 'en-US'). */ - language?: string; - - /** Custom vocabulary phrases for better accuracy. */ - vocabulary?: string[]; -} - -/** Result of a completed transcription session. */ -export interface TranscriptionResult { - /** Full transcribed text. */ - text: string; - - /** Confidence score (0-1), if available. */ - confidence?: number; - - /** Duration of audio in seconds. */ - durationSeconds?: number; - - /** Which provider produced this result. */ - provider: string; -} diff --git a/vendor/bytelyst/speech/tsconfig.json b/vendor/bytelyst/speech/tsconfig.json deleted file mode 100644 index 81f2cd1..0000000 --- a/vendor/bytelyst/speech/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["dist", "src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/storage/package.json b/vendor/bytelyst/storage/package.json deleted file mode 100644 index 4e46626..0000000 --- a/vendor/bytelyst/storage/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@bytelyst/storage", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./testing": { - "import": "./dist/testing.js", - "types": "./dist/testing.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "dependencies": { - "@azure/storage-blob": ">=12.0.0" - }, - "devDependencies": { - "vitest": "^3.0.0" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts b/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts deleted file mode 100644 index 56f0c45..0000000 --- a/vendor/bytelyst/storage/src/__tests__/memory-storage.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Tests for MemoryStorageProvider. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { MemoryStorageProvider } from '../providers/memory.js'; -import type { StorageBucket } from '../types.js'; - -describe('MemoryStorageProvider', () => { - let provider: MemoryStorageProvider; - let bucket: StorageBucket; - - beforeEach(() => { - provider = new MemoryStorageProvider(); - bucket = provider.getBucket('test-bucket'); - }); - - it('isHealthy returns true', async () => { - expect(await provider.isHealthy()).toBe(true); - }); - - it('returns same bucket instance', () => { - const b1 = provider.getBucket('x'); - const b2 = provider.getBucket('x'); - expect(b1).toBe(b2); - }); - - describe('bucket operations', () => { - it('upload + download', async () => { - await bucket.upload('file.txt', 'hello world', { contentType: 'text/plain' }); - const data = await bucket.download('file.txt'); - expect(data.toString()).toBe('hello world'); - }); - - it('upload returns metadata', async () => { - const meta = await bucket.upload('file.txt', Buffer.from('test'), { - contentType: 'text/plain', - metadata: { foo: 'bar' }, - }); - expect(meta.key).toBe('file.txt'); - expect(meta.size).toBe(4); - expect(meta.contentType).toBe('text/plain'); - expect(meta.metadata?.foo).toBe('bar'); - }); - - it('download throws for missing blob', async () => { - await expect(bucket.download('missing')).rejects.toThrow('not found'); - }); - - it('exists returns true/false', async () => { - expect(await bucket.exists('file.txt')).toBe(false); - await bucket.upload('file.txt', 'data'); - expect(await bucket.exists('file.txt')).toBe(true); - }); - - it('delete removes blob', async () => { - await bucket.upload('file.txt', 'data'); - await bucket.delete('file.txt'); - expect(await bucket.exists('file.txt')).toBe(false); - }); - - it('list returns all blobs', async () => { - await bucket.upload('a/1.txt', 'data'); - await bucket.upload('a/2.txt', 'data'); - await bucket.upload('b/1.txt', 'data'); - - const all = await bucket.list(); - expect(all).toHaveLength(3); - - const prefixed = await bucket.list('a/'); - expect(prefixed).toHaveLength(2); - }); - - it('getSignedUrl returns memory URL', async () => { - const url = await bucket.getSignedUrl('file.txt'); - expect(url).toContain('memory://'); - expect(url).toContain('file.txt'); - }); - }); -}); diff --git a/vendor/bytelyst/storage/src/factory.ts b/vendor/bytelyst/storage/src/factory.ts deleted file mode 100644 index c37c2e4..0000000 --- a/vendor/bytelyst/storage/src/factory.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Storage provider factory. - * - * Creates a StorageProvider based on STORAGE_PROVIDER env var or explicit type. - * Defaults to 'azure' for backward compatibility. - */ - -import { MemoryStorageProvider } from './providers/memory.js'; -import type { StorageProvider, StorageProviderType } from './types.js'; - -let _provider: StorageProvider | null = null; - -/** - * Get the singleton storage provider. - */ -export async function getStorage(): Promise { - if (!_provider) { - const providerType = (process.env.STORAGE_PROVIDER || 'azure') as StorageProviderType; - _provider = await createStorageProvider(providerType); - } - return _provider; -} - -/** - * Create a storage provider by type. - */ -export async function createStorageProvider(type: StorageProviderType): Promise { - switch (type) { - case 'azure': { - const { AzureBlobStorageProvider } = await import('./providers/azure-blob.js'); - return new AzureBlobStorageProvider(); - } - case 'memory': - return new MemoryStorageProvider(); - default: - throw new Error(`Unknown STORAGE_PROVIDER: '${type}'. Valid: azure, memory`); - } -} - -/** - * Set the singleton storage provider (for testing). - */ -export function setStorage(provider: StorageProvider): void { - _provider = provider; -} - -/** - * @internal - */ -export function _resetStorage(): void { - _provider = null; -} diff --git a/vendor/bytelyst/storage/src/index.ts b/vendor/bytelyst/storage/src/index.ts deleted file mode 100644 index e704073..0000000 --- a/vendor/bytelyst/storage/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type { - StorageProvider, - StorageBucket, - UploadOptions, - SignedUrlOptions, - BlobMeta, - StorageProviderType, -} from './types.js'; - -export { getStorage, createStorageProvider, setStorage, _resetStorage } from './factory.js'; -export { AzureBlobStorageProvider, type AzureBlobProviderConfig } from './providers/azure-blob.js'; -export { MemoryStorageProvider } from './providers/memory.js'; diff --git a/vendor/bytelyst/storage/src/providers/azure-blob.ts b/vendor/bytelyst/storage/src/providers/azure-blob.ts deleted file mode 100644 index 86d3a9b..0000000 --- a/vendor/bytelyst/storage/src/providers/azure-blob.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Azure Blob Storage provider. - * - * Wraps @azure/storage-blob behind the cloud-agnostic StorageProvider interface. - */ - -import type { - BlobMeta, - SignedUrlOptions, - StorageBucket, - StorageProvider, - UploadOptions, -} from '../types.js'; - -export interface AzureBlobProviderConfig { - connectionString?: string; - accountName?: string; - accountKey?: string; - blobEndpoint?: string; - publicBlobEndpoint?: string; -} - -function parseConnectionString(connectionString: string): Partial { - const parts = new Map(); - - for (const segment of connectionString.split(';')) { - const [key, ...rest] = segment.split('='); - if (!key || rest.length === 0) continue; - parts.set(key, rest.join('=')); - } - - return { - accountName: parts.get('AccountName'), - accountKey: parts.get('AccountKey'), - blobEndpoint: parts.get('BlobEndpoint'), - }; -} - -export class AzureBlobStorageProvider implements StorageProvider { - private client: unknown = null; - private config: AzureBlobProviderConfig; - private buckets = new Map(); - - constructor(config?: AzureBlobProviderConfig) { - const envConfig = config ?? { - connectionString: process.env.AZURE_BLOB_CONNECTION_STRING, - accountName: process.env.AZURE_BLOB_ACCOUNT_NAME, - accountKey: process.env.AZURE_BLOB_ACCOUNT_KEY, - publicBlobEndpoint: process.env.AZURE_BLOB_PUBLIC_ENDPOINT, - }; - - const parsed = envConfig.connectionString - ? parseConnectionString(envConfig.connectionString) - : undefined; - - this.config = { - ...parsed, - ...envConfig, - accountName: envConfig.accountName ?? parsed?.accountName, - accountKey: envConfig.accountKey ?? parsed?.accountKey, - blobEndpoint: envConfig.blobEndpoint ?? parsed?.blobEndpoint, - }; - } - - private async getClient() { - if (!this.client) { - const { BlobServiceClient } = await import('@azure/storage-blob'); - if (this.config.connectionString) { - this.client = BlobServiceClient.fromConnectionString(this.config.connectionString); - } else if (this.config.accountName && this.config.accountKey) { - const { StorageSharedKeyCredential } = await import('@azure/storage-blob'); - const cred = new StorageSharedKeyCredential( - this.config.accountName, - this.config.accountKey - ); - const endpoint = - this.config.blobEndpoint ?? `https://${this.config.accountName}.blob.core.windows.net`; - this.client = new BlobServiceClient(endpoint, cred); - } else { - throw new Error( - 'AzureBlobStorageProvider requires AZURE_BLOB_CONNECTION_STRING or AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY' - ); - } - } - return this.client as import('@azure/storage-blob').BlobServiceClient; - } - - getBucket(name: string): StorageBucket { - let bucket = this.buckets.get(name); - if (!bucket) { - bucket = new AzureBlobBucket(name, () => this.getClient(), this.config); - this.buckets.set(name, bucket); - } - return bucket; - } - - async isHealthy(): Promise { - try { - const client = await this.getClient(); - // List one container to verify connectivity - const iter = client.listContainers(); - await iter.next(); - return true; - } catch { - return false; - } - } -} - -class AzureBlobBucket implements StorageBucket { - constructor( - private containerName: string, - private getClient: () => Promise, - private config: AzureBlobProviderConfig - ) {} - - private async containerClient() { - const client = await this.getClient(); - const container = client.getContainerClient(this.containerName); - await container.createIfNotExists(); - return container; - } - - async upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise { - const container = await this.containerClient(); - const blockBlob = container.getBlockBlobClient(key); - const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); - await blockBlob.upload(buf, buf.length, { - blobHTTPHeaders: { blobContentType: options?.contentType }, - metadata: options?.metadata, - }); - return { - key, - size: buf.length, - contentType: options?.contentType, - lastModified: new Date(), - metadata: options?.metadata, - }; - } - - async download(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - const response = await blob.downloadToBuffer(); - return response; - } - - async delete(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - await blob.deleteIfExists(); - } - - async exists(key: string): Promise { - const container = await this.containerClient(); - const blob = container.getBlobClient(key); - return blob.exists(); - } - - async list(prefix?: string): Promise { - const container = await this.containerClient(); - const results: BlobMeta[] = []; - for await (const blob of container.listBlobsFlat({ prefix: prefix ?? undefined })) { - results.push({ - key: blob.name, - size: blob.properties.contentLength ?? undefined, - contentType: blob.properties.contentType ?? undefined, - lastModified: blob.properties.lastModified, - }); - } - return results; - } - - async getSignedUrl(key: string, options?: SignedUrlOptions): Promise { - const { generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } = - await import('@azure/storage-blob'); - - if (!this.config.accountName || !this.config.accountKey) { - throw new Error('Signed URLs require accountName + accountKey'); - } - - const cred = new StorageSharedKeyCredential(this.config.accountName, this.config.accountKey); - const expiresOn = new Date(Date.now() + (options?.expiresIn ?? 3600) * 1000); - const permissions = BlobSASPermissions.parse(options?.permissions === 'write' ? 'w' : 'r'); - - const sas = generateBlobSASQueryParameters( - { - containerName: this.containerName, - blobName: key, - permissions, - expiresOn, - }, - cred - ); - - const baseUrl = - this.config.publicBlobEndpoint ?? - this.config.blobEndpoint ?? - `https://${this.config.accountName}.blob.core.windows.net`; - - return `${baseUrl.replace(/\/$/, '')}/${this.containerName}/${key}?${sas.toString()}`; - } -} diff --git a/vendor/bytelyst/storage/src/providers/memory.ts b/vendor/bytelyst/storage/src/providers/memory.ts deleted file mode 100644 index 4bb2710..0000000 --- a/vendor/bytelyst/storage/src/providers/memory.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * In-memory storage provider — for testing and local dev. - */ - -import type { - BlobMeta, - SignedUrlOptions, - StorageBucket, - StorageProvider, - UploadOptions, -} from '../types.js'; - -export class MemoryStorageProvider implements StorageProvider { - private buckets = new Map(); - - getBucket(name: string): StorageBucket { - let bucket = this.buckets.get(name); - if (!bucket) { - bucket = new MemoryBucket(name); - this.buckets.set(name, bucket); - } - return bucket; - } - - async isHealthy(): Promise { - return true; - } - - clear(): void { - this.buckets.clear(); - } -} - -class MemoryBucket implements StorageBucket { - private blobs = new Map(); - - constructor(private name: string) {} - - async upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise { - const buf = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data); - const meta: BlobMeta = { - key, - size: buf.length, - contentType: options?.contentType ?? 'application/octet-stream', - lastModified: new Date(), - metadata: options?.metadata, - }; - this.blobs.set(key, { data: buf, meta }); - return meta; - } - - async download(key: string): Promise { - const entry = this.blobs.get(key); - if (!entry) throw new Error(`Blob '${key}' not found in bucket '${this.name}'`); - return entry.data; - } - - async delete(key: string): Promise { - this.blobs.delete(key); - } - - async exists(key: string): Promise { - return this.blobs.has(key); - } - - async list(prefix?: string): Promise { - const results: BlobMeta[] = []; - for (const [key, entry] of this.blobs) { - if (!prefix || key.startsWith(prefix)) { - results.push(entry.meta); - } - } - return results; - } - - async getSignedUrl(key: string, _options?: SignedUrlOptions): Promise { - return `memory://${this.name}/${key}?signed=true`; - } -} diff --git a/vendor/bytelyst/storage/src/testing.ts b/vendor/bytelyst/storage/src/testing.ts deleted file mode 100644 index c3cecff..0000000 --- a/vendor/bytelyst/storage/src/testing.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Test helpers for @bytelyst/storage. - */ - -import { setStorage, _resetStorage } from './factory.js'; -import { MemoryStorageProvider } from './providers/memory.js'; - -let _testProvider: MemoryStorageProvider | null = null; - -export function setTestStorageProvider(): MemoryStorageProvider { - _testProvider = new MemoryStorageProvider(); - setStorage(_testProvider); - return _testProvider; -} - -export function clearTestStorage(): void { - _testProvider?.clear(); -} - -export function resetTestStorage(): void { - _testProvider?.clear(); - _testProvider = null; - _resetStorage(); -} diff --git a/vendor/bytelyst/storage/src/types.ts b/vendor/bytelyst/storage/src/types.ts deleted file mode 100644 index b1f7211..0000000 --- a/vendor/bytelyst/storage/src/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Cloud-agnostic storage interfaces. - * - * Provides StorageProvider and StorageBucket abstractions - * that work with Azure Blob, S3, R2, or in-memory storage. - */ - -export interface StorageProvider { - /** Get or create a bucket/container. */ - getBucket(name: string): StorageBucket; - - /** Check if storage is configured and reachable. */ - isHealthy(): Promise; -} - -export interface StorageBucket { - /** Upload a blob. */ - upload( - key: string, - data: Buffer | Uint8Array | string, - options?: UploadOptions - ): Promise; - - /** Download a blob as a Buffer. */ - download(key: string): Promise; - - /** Delete a blob. */ - delete(key: string): Promise; - - /** Check if a blob exists. */ - exists(key: string): Promise; - - /** List blobs with optional prefix. */ - list(prefix?: string): Promise; - - /** Get a signed URL for temporary access. */ - getSignedUrl(key: string, options?: SignedUrlOptions): Promise; -} - -export interface UploadOptions { - contentType?: string; - metadata?: Record; -} - -export interface SignedUrlOptions { - /** Expiry in seconds (default 3600). */ - expiresIn?: number; - permissions?: 'read' | 'write'; -} - -export interface BlobMeta { - key: string; - size?: number; - contentType?: string; - lastModified?: Date; - metadata?: Record; -} - -export type StorageProviderType = 'azure' | 'memory'; diff --git a/vendor/bytelyst/storage/tsconfig.json b/vendor/bytelyst/storage/tsconfig.json deleted file mode 100644 index 5edad81..0000000 --- a/vendor/bytelyst/storage/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/subscription-client/package.json b/vendor/bytelyst/subscription-client/package.json deleted file mode 100644 index b5dce4a..0000000 --- a/vendor/bytelyst/subscription-client/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/subscription-client", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/subscription-client/src/client.test.ts b/vendor/bytelyst/subscription-client/src/client.test.ts deleted file mode 100644 index 33b8b8e..0000000 --- a/vendor/bytelyst/subscription-client/src/client.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { createSubscriptionClient } from './client.js'; -import type { SubscriptionDoc, PlanConfig } from './types.js'; - -const baseConfig = { - baseUrl: 'http://localhost:4003/api', - productId: 'testapp', - userId: 'user-1', - getAccessToken: () => 'test-token', -}; - -function mockSub(overrides?: Partial): SubscriptionDoc { - return { - id: 'sub-1', - productId: 'testapp', - userId: 'user-1', - plan: 'pro', - status: 'active', - currentPeriodStart: '2026-01-01T00:00:00Z', - currentPeriodEnd: '2026-12-31T23:59:59Z', - cancelAtPeriodEnd: false, - monthlyPrice: 999, - tokensIncluded: 10000, - tokensUsed: 500, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -function mockPlan(overrides?: Partial): PlanConfig { - return { - id: 'plan-1', - productId: 'testapp', - name: 'pro', - displayName: 'Pro', - price: 999, - tokens: 10000, - words: 0, - dictations: 0, - features: ['ai_coaching', 'advanced_stats'], - active: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - ...overrides, - }; -} - -describe('createSubscriptionClient', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should get subscription by userId', async () => { - const sub = mockSub(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getMySubscription(); - - expect(result).toEqual(sub); - const fetchMock = globalThis.fetch as ReturnType; - expect(fetchMock).toHaveBeenCalledWith( - 'http://localhost:4003/api/subscriptions/user-1', - expect.any(Object) - ); - }); - - it('should return null for 404 on getMySubscription', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 404, - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getMySubscription(); - expect(result).toBeNull(); - }); - - it('should unwrap .plans from getPlans response', async () => { - const plans = [mockPlan(), mockPlan({ name: 'free', displayName: 'Free', features: [] })]; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ plans }), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.getPlans(); - expect(result).toHaveLength(2); - expect(result[0].name).toBe('pro'); - }); - - it('should start a trial', async () => { - const sub = mockSub({ status: 'trialing' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.startTrial(); - expect(result.status).toBe('trialing'); - }); - - it('should cancel subscription', async () => { - const sub = mockSub({ cancelAtPeriodEnd: true }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(sub), - }) - ); - - const client = createSubscriptionClient(baseConfig); - const result = await client.cancelSubscription(); - expect(result.cancelAtPeriodEnd).toBe(true); - }); - - it('should report isPro correctly from cache', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'pro', status: 'active' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - expect(client.isPro()).toBe(false); // no cache yet - - await client.getMySubscription(); - expect(client.isPro()).toBe(true); - }); - - it('should report isPro false for free plan', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'free', status: 'active' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - expect(client.isPro()).toBe(false); - }); - - it('should check hasFeature from cached plans', async () => { - let callIndex = 0; - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(() => { - callIndex++; - if (callIndex === 1) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockSub({ plan: 'pro' })), - }); - } - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - plans: [mockPlan({ name: 'pro', features: ['ai_coaching', 'advanced_stats'] })], - }), - }); - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.refresh(); - - expect(client.hasFeature('ai_coaching')).toBe(true); - expect(client.hasFeature('nonexistent')).toBe(false); - }); - - it('should report isTrialing correctly', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ status: 'trialing' })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - expect(client.isTrialing()).toBe(true); - }); - - it('should calculate daysRemaining', async () => { - const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub({ currentPeriodEnd: futureDate })), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getMySubscription(); - const days = client.daysRemaining(); - expect(days).toBeGreaterThanOrEqual(10); - expect(days).toBeLessThanOrEqual(11); - }); - - it('should persist and restore from storage', async () => { - const store: Record = {}; - const storage = { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - }; - - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockSub()), - }) - ); - - const client1 = createSubscriptionClient({ ...baseConfig, storage }); - await client1.getMySubscription(); - - expect(store['testapp-subscription']).toBeDefined(); - - // Create new client — should restore from storage - const client2 = createSubscriptionClient({ ...baseConfig, storage }); - expect(client2.getCachedSubscription()).not.toBeNull(); - expect(client2.isPro()).toBe(true); - }); - - it('should send correct headers', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ plans: [] }), - }) - ); - - const client = createSubscriptionClient(baseConfig); - await client.getPlans(); - - const fetchMock = globalThis.fetch as ReturnType; - const callHeaders = fetchMock.mock.calls[0][1].headers as Record; - expect(callHeaders['x-product-id']).toBe('testapp'); - expect(callHeaders['Authorization']).toBe('Bearer test-token'); - }); - - it('should throw on non-ok response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - }) - ); - - const client = createSubscriptionClient(baseConfig); - await expect(client.getPlans()).rejects.toThrow('getPlans failed: 500'); - }); -}); diff --git a/vendor/bytelyst/subscription-client/src/client.ts b/vendor/bytelyst/subscription-client/src/client.ts deleted file mode 100644 index 0922773..0000000 --- a/vendor/bytelyst/subscription-client/src/client.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Browser/React Native-safe subscription client for platform-service. - * - * Wraps platform-service /subscriptions/* + /plans/* endpoints. - * Caches subscription and plans for offline reads. - * No Node.js dependencies — uses globalThis.fetch. - */ - -import type { - PlanConfig, - SubscriptionClient, - SubscriptionClientConfig, - SubscriptionDoc, -} from './types.js'; - -function generateRequestId(): string { - return typeof globalThis.crypto?.randomUUID === 'function' - ? globalThis.crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -export function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient { - const { baseUrl, productId, userId, getAccessToken, storage } = config; - - const SUB_KEY = `${productId}-subscription`; - const PLANS_KEY = `${productId}-plans`; - - let cachedSub: SubscriptionDoc | null = null; - let cachedPlans: PlanConfig[] = []; - - // Restore from storage on creation - if (storage) { - try { - const raw = storage.getItem(SUB_KEY); - if (raw) cachedSub = JSON.parse(raw) as SubscriptionDoc; - } catch { - /* ignore */ - } - try { - const raw = storage.getItem(PLANS_KEY); - if (raw) cachedPlans = JSON.parse(raw) as PlanConfig[]; - } catch { - /* ignore */ - } - } - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': generateRequestId(), - }; - const token = getAccessToken(); - if (token) h['Authorization'] = `Bearer ${token}`; - return h; - } - - function persistSub(sub: SubscriptionDoc | null): void { - cachedSub = sub; - if (storage) { - try { - storage.setItem(SUB_KEY, JSON.stringify(sub)); - } catch { - /* ignore */ - } - } - } - - function persistPlans(plans: PlanConfig[]): void { - cachedPlans = plans; - if (storage) { - try { - storage.setItem(PLANS_KEY, JSON.stringify(plans)); - } catch { - /* ignore */ - } - } - } - - async function getMySubscription(): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - headers: headers(), - }); - if (res.status === 404) { - persistSub(null); - return null; - } - if (!res.ok) throw new Error(`getMySubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function getPlans(): Promise { - const res = await globalThis.fetch(`${baseUrl}/plans`, { headers: headers() }); - if (!res.ok) throw new Error(`getPlans failed: ${res.status}`); - const data = (await res.json()) as { plans: PlanConfig[] }; - const plans = data.plans; - persistPlans(plans); - return plans; - } - - async function startTrial(planName = 'pro'): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions`, { - method: 'POST', - headers: headers(), - body: JSON.stringify({ userId, productId, plan: planName, status: 'trialing' }), - }); - if (!res.ok) throw new Error(`startTrial failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function cancelSubscription(): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify({ cancelAtPeriodEnd: true }), - }); - if (!res.ok) throw new Error(`cancelSubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - async function updateSubscription(updates: Partial): Promise { - const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { - method: 'PUT', - headers: headers(), - body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error(`updateSubscription failed: ${res.status}`); - const sub = (await res.json()) as SubscriptionDoc; - persistSub(sub); - return sub; - } - - function isPro(): boolean { - if (!cachedSub) return false; - return ( - cachedSub.plan !== 'free' && - (cachedSub.status === 'active' || cachedSub.status === 'trialing') - ); - } - - function isTrialing(): boolean { - return cachedSub?.status === 'trialing' || false; - } - - function hasFeature(feature: string): boolean { - if (!cachedSub) return false; - const plan = cachedPlans.find(p => p.name === cachedSub!.plan); - if (!plan) return false; - return plan.features.includes(feature); - } - - function daysRemaining(): number | null { - if (!cachedSub) return null; - const end = new Date(cachedSub.currentPeriodEnd).getTime(); - const now = Date.now(); - const diff = end - now; - if (diff <= 0) return 0; - return Math.ceil(diff / (1000 * 60 * 60 * 24)); - } - - function getCachedSubscription(): SubscriptionDoc | null { - return cachedSub; - } - - function getCachedPlans(): PlanConfig[] { - return cachedPlans; - } - - async function refresh(): Promise { - await Promise.all([getMySubscription(), getPlans()]); - } - - return { - getMySubscription, - getPlans, - startTrial, - cancelSubscription, - updateSubscription, - isPro, - isTrialing, - hasFeature, - daysRemaining, - getCachedSubscription, - getCachedPlans, - refresh, - }; -} diff --git a/vendor/bytelyst/subscription-client/src/index.ts b/vendor/bytelyst/subscription-client/src/index.ts deleted file mode 100644 index bd82298..0000000 --- a/vendor/bytelyst/subscription-client/src/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -export interface SubscriptionDoc { - id: string; - userId: string; - productId?: string; - plan: string; - status: 'active' | 'trialing' | 'past_due' | 'cancelled' | 'none'; - currentPeriodStart?: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice?: number; - tokensIncluded?: number; - tokensUsed?: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - features?: string[]; - createdAt?: string; - updatedAt?: string; -} - -export interface PlanConfig { - id: string; - name: string; - displayName: string; - price: number; - features: string[]; -} - -export interface SubscriptionClientOptions { - baseUrl: string; - productId: string; - userId: string; - getAccessToken: () => string; -} - -export interface SubscriptionClient { - getMySubscription(): Promise; - getPlans(): Promise; - cancelSubscription(): Promise; - isPro(): boolean; - isTrialing(): boolean; - hasFeature(feature: string): boolean; - daysRemaining(): number | null; -} - -function trimTrailingSlash(url: string): string { - return url.replace(/\/+$/, ''); -} - -export function createSubscriptionClient(opts: SubscriptionClientOptions): SubscriptionClient { - const base = trimTrailingSlash(opts.baseUrl); - let cached: SubscriptionDoc | null | undefined; - - async function request(path: string, init?: RequestInit): Promise { - const token = opts.getAccessToken(); - const headers = new Headers(init?.headers); - headers.set('Authorization', `Bearer ${token}`); - headers.set('X-Product-Id', opts.productId); - if (!headers.has('Content-Type') && init?.body !== undefined) { - headers.set('Content-Type', 'application/json'); - } - const res = await fetch(`${base}${path}`, { ...init, headers }); - if (res.status === 404) { - return null as T; - } - if (!res.ok) { - throw new Error(`Subscription API error: ${res.status} ${res.statusText}`); - } - if (res.status === 204) { - return null as T; - } - const text = await res.text(); - if (!text) { - return null as T; - } - return JSON.parse(text) as T; - } - - function subscriptionFromCache(): SubscriptionDoc | null { - if (cached === undefined || cached === null) { - return null; - } - return cached; - } - - return { - async getMySubscription(): Promise { - const data = await request('/billing/subscriptions/me'); - cached = data; - return data; - }, - - async getPlans(): Promise { - const data = await request('/billing/plans'); - if (data == null) { - return []; - } - if (Array.isArray(data)) { - return data; - } - if (data && typeof data === 'object' && 'plans' in data && Array.isArray(data.plans)) { - return data.plans; - } - return []; - }, - - async cancelSubscription(): Promise { - const data = await request('/billing/subscriptions/cancel', { - method: 'POST', - }); - if (data === null) { - throw new Error('Cancel subscription returned no body'); - } - cached = data; - return data; - }, - - isPro(): boolean { - const sub = subscriptionFromCache(); - if (!sub) { - return false; - } - const paid = sub.status === 'active' || sub.status === 'trialing'; - const planLower = sub.plan.toLowerCase(); - const notFree = planLower !== 'free' && planLower !== 'none'; - return paid && notFree; - }, - - isTrialing(): boolean { - return subscriptionFromCache()?.status === 'trialing'; - }, - - hasFeature(feature: string): boolean { - const sub = subscriptionFromCache(); - if (!sub?.features?.length) { - return false; - } - return sub.features.includes(feature); - }, - - daysRemaining(): number | null { - const sub = subscriptionFromCache(); - if (!sub?.currentPeriodEnd) { - return null; - } - const end = new Date(sub.currentPeriodEnd).getTime(); - if (Number.isNaN(end)) { - return null; - } - const ms = end - Date.now(); - if (ms <= 0) { - return 0; - } - return Math.ceil(ms / (1000 * 60 * 60 * 24)); - }, - }; -} diff --git a/vendor/bytelyst/subscription-client/src/types.ts b/vendor/bytelyst/subscription-client/src/types.ts deleted file mode 100644 index 6092f49..0000000 --- a/vendor/bytelyst/subscription-client/src/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Types for @bytelyst/subscription-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface SubscriptionDoc { - id: string; - productId: string; - userId: string; - plan: 'free' | 'pro' | 'enterprise'; - status: 'active' | 'cancelled' | 'past_due' | 'trialing'; - currentPeriodStart: string; - currentPeriodEnd: string; - cancelAtPeriodEnd: boolean; - monthlyPrice: number; - tokensIncluded: number; - tokensUsed: number; - stripeCustomerId?: string; - stripeSubscriptionId?: string; - createdAt: string; - updatedAt: string; -} - -export interface PlanConfig { - id: string; - productId: string; - name: string; - displayName: string; - price: number; - tokens: number; - words: number; - dictations: number; - features: string[]; - stripePriceId?: string; - active: boolean; - createdAt: string; - updatedAt: string; -} - -export interface SubscriptionClientConfig { - /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ - baseUrl: string; - - /** Product identifier sent as x-product-id header on every request. */ - productId: string; - - /** User ID — subscription routes are keyed by userId, not id. */ - userId: string; - - /** Returns a JWT access token, or null if not authenticated. */ - getAccessToken: () => string | null; - - /** Optional persistent storage adapter for cache. */ - storage?: { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - }; -} - -export interface SubscriptionClient { - // Server-authoritative checks - getMySubscription(): Promise; - getPlans(): Promise; - startTrial(planName?: string): Promise; - cancelSubscription(): Promise; - updateSubscription(updates: Partial): Promise; - - // Client-side helpers (cached, offline-safe) - isPro(): boolean; - isTrialing(): boolean; - hasFeature(feature: string): boolean; - daysRemaining(): number | null; - getCachedSubscription(): SubscriptionDoc | null; - getCachedPlans(): PlanConfig[]; - refresh(): Promise; -} diff --git a/vendor/bytelyst/subscription-client/tsconfig.json b/vendor/bytelyst/subscription-client/tsconfig.json deleted file mode 100644 index 7d61ee3..0000000 --- a/vendor/bytelyst/subscription-client/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "lib": ["ES2022", "DOM"] - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/survey-client/README.md b/vendor/bytelyst/survey-client/README.md deleted file mode 100644 index d86a83a..0000000 --- a/vendor/bytelyst/survey-client/README.md +++ /dev/null @@ -1,349 +0,0 @@ -# @bytelyst/survey-client - -TypeScript client for the ByteLyst Survey platform. Provides survey discovery, question flow management, response submission, and offline caching. - -## Installation - -```bash -npm install @bytelyst/survey-client -# or -pnpm add @bytelyst/survey-client -``` - -## Quick Start - -```typescript -import { createSurveyClient } from '@bytelyst/survey-client'; - -const client = createSurveyClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: async () => { - return localStorage.getItem('token'); - } -}); - -// Check for active survey -const [survey, error] = await client.getActiveSurvey(); -if (survey) { - console.log('Survey available:', survey.title); -} -``` - -## API Reference - -### `createSurveyClient(config)` - -Creates a new survey client instance. - -**Config:** -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `baseURL` | string | Yes | API base URL | -| `productId` | string | Yes | Product identifier | -| `getAuthToken` | () => Promise | Yes | Function to retrieve JWT token | -| `enableOfflineCache` | boolean | No | Enable offline response caching (default: true) | - -### Methods - -#### `getActiveSurvey()` -Check if there's an active survey for the current user. - -```typescript -const [survey, error] = await client.getActiveSurvey(); -// Returns: ActiveSurvey | null -``` - -#### `startSurvey(surveyId: string)` -Start a survey session. - -```typescript -const [state, error] = await client.startSurvey('srv_123'); -// Returns: { surveyStateId: string, currentQuestionIndex: number } -``` - -#### `submitAnswer(surveyId: string, questionId: string, answer: SurveyAnswer)` -Submit an answer for a specific question. - -```typescript -const [result, error] = await client.submitAnswer('srv_123', 'q1', { - type: 'rating', - value: { value: 8 } -}); -// Returns: { currentQuestionIndex: number, nextQuestionId: string, isComplete: boolean } -``` - -**Answer Types:** -```typescript -// Single choice -{ type: 'single_choice', value: { value: 'option_1' } } - -// Multiple choice -{ type: 'multiple_choice', value: { values: ['opt_1', 'opt_2'] } } - -// Rating/NPS/Scale -{ type: 'rating', value: { value: 8 } } - -// Text -{ type: 'text', value: { value: 'My feedback here' } } - -// Ranking -{ type: 'ranking', value: { order: ['opt_1', 'opt_2', 'opt_3'] } } -``` - -#### `completeSurvey(surveyId: string)` -Mark survey as complete and claim any incentive. - -```typescript -const [completion, error] = await client.completeSurvey('srv_123'); -// Returns: { success: boolean, incentiveClaimed: boolean, incentiveType?: string, incentiveAmount?: number } -``` - -#### `dismissSurvey(surveyId: string)` -Dismiss survey without completing. - -```typescript -await client.dismissSurvey('srv_123'); -``` - -#### `startPolling(intervalMs: number, callback: (survey) => void)` -Start polling for active surveys. - -```typescript -client.startPolling(60000, (survey) => { - if (survey) { - showSurveyModal(survey); - } -}); -``` - -#### `stopPolling()` -Stop survey polling. - -```typescript -client.stopPolling(); -``` - -#### `flushOfflineQueue()` -Manually flush any cached offline responses. - -```typescript -await client.flushOfflineQueue(); -``` - -## React Integration - -### Hook Usage - -```typescript -import { useSurvey } from './hooks/useSurvey'; - -function SurveyWidget() { - const { - activeSurvey, - currentQuestion, - submitAnswer, - completeSurvey, - progress - } = useSurvey({ - autoStart: true, - pollingInterval: 60000 - }); - - if (!activeSurvey) return null; - - return ( - - ); -} -``` - -### Complete Survey Flow Example - -```typescript -function SurveyFlow() { - const client = useSurveyClient(); - const [survey, setSurvey] = useState(null); - const [currentIndex, setCurrentIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - - useEffect(() => { - loadSurvey(); - }, []); - - async function loadSurvey() { - const [activeSurvey] = await client.getActiveSurvey(); - if (activeSurvey) { - await client.startSurvey(activeSurvey.id); - setSurvey(activeSurvey); - } - } - - async function handleAnswer(questionId: string, answer: SurveyAnswer) { - const [result] = await client.submitAnswer(survey!.id, questionId, answer); - - setAnswers(prev => ({ ...prev, [questionId]: answer })); - setCurrentIndex(result.currentQuestionIndex); - - if (result.isComplete) { - const [completion] = await client.completeSurvey(survey!.id); - if (completion.incentiveClaimed) { - showIncentiveToast(completion.incentiveAmount, completion.incentiveType); - } - } - } - - if (!survey) return null; - - const question = survey.questions[currentIndex]; - const isLast = currentIndex === survey.questions.length - 1; - - return ( - handleAnswer(question.id, answer)} - onSkip={question.required ? undefined : () => handleSkip(question.id)} - /> - ); -} -``` - -## Offline Support - -The client automatically caches responses when offline: - -```typescript -const client = createSurveyClient({ - baseURL: 'https://api.bytelyst.io/v1', - productId: 'lysnrai', - getAuthToken: () => getToken(), - enableOfflineCache: true // Enabled by default -}); - -// Responses are queued when offline -// Flush queue manually or on reconnect -window.addEventListener('online', () => { - client.flushOfflineQueue(); -}); -``` - -## Types - -```typescript -interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - currentQuestionIndex: number; - incentive?: { - type: 'pro_days' | 'credits'; - amount: number; - }; -} - -interface Question { - id: string; - type: 'single_choice' | 'multiple_choice' | 'rating' | 'scale' | - 'nps' | 'text_short' | 'text_long' | 'ranking' | 'dropdown'; - text: string; - description?: string; - required: boolean; - options?: QuestionOption[]; - minValue?: number; - maxValue?: number; - maxLength?: number; - showIf?: ShowIfCondition; -} - -interface QuestionOption { - id: string; - text: string; - emoji?: string; -} - -interface SurveyAnswer { - type: string; - value: Record; -} - -interface SurveyConfig { - baseURL: string; - productId: string; - getAuthToken: () => Promise; - enableOfflineCache?: boolean; -} -``` - -## Question Type Reference - -| Type | Answer Format | UI Component | -|------|--------------|--------------| -| `single_choice` | `{ value: string }` | Radio group | -| `multiple_choice` | `{ values: string[] }` | Checkboxes | -| `rating` | `{ value: number }` | Star rating (1-5) | -| `scale` | `{ value: number }` | Numeric slider | -| `nps` | `{ value: number }` | 0-10 buttons | -| `text_short` | `{ value: string }` | Single line input | -| `text_long` | `{ value: string }` | Textarea | -| `ranking` | `{ order: string[] }` | Drag-to-sort | -| `dropdown` | `{ value: string }` | Select dropdown | - -## Conditional Logic - -Questions can be shown/hidden based on previous answers: - -```typescript -// Question only shows if q1 answer is NOT 9 or 10 -{ - id: 'q2', - type: 'text_long', - text: 'What could we improve?', - showIf: { - questionId: 'q1', - operator: 'not_equals', - value: ['9', '10'] - } -} -``` - -**Operators:** `equals`, `not_equals`, `greater_than`, `less_than`, `contains` - -## Error Handling - -```typescript -const [data, error] = await client.submitAnswer('srv_123', 'q1', answer); - -if (error) { - switch (error.code) { - case 'SURVEY_NOT_FOUND': - console.error('Survey expired or unavailable'); - break; - case 'ALREADY_COMPLETED': - console.error('User already completed this survey'); - break; - case 'VALIDATION_ERROR': - console.error('Invalid answer format'); - break; - default: - console.error('Survey error:', error.message); - } -} -``` - -## Browser Support - -- Chrome 90+ -- Firefox 88+ -- Safari 14+ -- Edge 90+ - -## License - -MIT © ByteLyst diff --git a/vendor/bytelyst/survey-client/package.json b/vendor/bytelyst/survey-client/package.json deleted file mode 100644 index 656a9b7..0000000 --- a/vendor/bytelyst/survey-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/survey-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe survey client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/survey-client/src/index.ts b/vendor/bytelyst/survey-client/src/index.ts deleted file mode 100644 index bb2681e..0000000 --- a/vendor/bytelyst/survey-client/src/index.ts +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Survey Client — Browser/React Native-safe survey client - * @module @bytelyst/survey-client - */ - -// ============================================================================= -// Types -// ============================================================================= - -export type QuestionType = - | 'single_choice' - | 'multiple_choice' - | 'rating' - | 'nps' - | 'text_short' - | 'text_long' - | 'dropdown' - | 'scale' - | 'ranking'; - -export interface QuestionOption { - id: string; - text: string; - emoji?: string; -} - -export interface Question { - id: string; - type: QuestionType; - text: string; - description?: string; - required: boolean; - options?: QuestionOption[]; - minLength?: number; - maxLength?: number; - minValue?: number; - maxValue?: number; -} - -export interface Survey { - id: string; - productId: string; - title: string; - description?: string; - questions: Question[]; - status: 'draft' | 'active' | 'paused' | 'closed'; - startsAt?: string; - endsAt?: string; - displayTrigger: - | { type: 'immediate' } - | { type: 'delay_seconds'; seconds: number } - | { type: 'event'; eventName: string } - | { type: 'page_view'; pagePattern: string }; - incentive?: { type: 'pro_days' | 'credits'; amount: number }; - createdAt: string; - updatedAt: string; - createdBy: string; -} - -export type QuestionAnswer = - | { type: 'single_choice'; optionId: string } - | { type: 'multiple_choice'; optionIds: string[] } - | { type: 'rating'; value: number } - | { type: 'nps'; value: number } - | { type: 'text'; value: string } - | { type: 'ranking'; rankedOptionIds: string[] }; - -export interface SurveyResponse { - id: string; - surveyId: string; - userId: string; - answers: Record; - currentQuestionIndex: number; - startedAt: string; - completedAt?: string; - isComplete: boolean; - incentiveClaimed: boolean; - incentiveClaimedAt?: string; - createdAt: string; - updatedAt: string; -} - -export interface SurveyClientConfig { - /** Platform service base URL */ - baseUrl: string; - /** Product ID */ - productId: string; - /** Auth token provider */ - getAuthToken: (() => string) | (() => Promise); - /** Platform identifier */ - platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; - /** App version */ - appVersion: string; - /** OS version */ - osVersion: string; - /** Optional country code */ - countryCode?: string; - /** Optional region code */ - regionCode?: string; - /** User segments (default: ['free']) */ - userSegments?: string[]; - /** Device ID for tracking */ - deviceId?: string; -} - -// ============================================================================= -// Client Factory -// ============================================================================= - -export interface ActiveSurvey { - id: string; - title: string; - description?: string; - questions: Question[]; - incentive?: { type: 'pro_days' | 'credits'; amount: number }; - displayTrigger: Survey['displayTrigger']; -} - -export interface SurveyClient { - /** Get active survey for current user (if any) */ - getActiveSurvey(): Promise<{ survey: ActiveSurvey | null }>; - /** Start a survey session */ - startSurvey(surveyId: string): Promise<{ - responseId: string; - startedAt: string; - currentQuestionIndex: number; - answers: Record; - }>; - /** Submit an answer */ - submitAnswer( - surveyId: string, - questionId: string, - answer: QuestionAnswer - ): Promise<{ - responseId: string; - currentQuestionIndex: number; - answers: Record; - }>; - /** Complete the survey */ - completeSurvey(surveyId: string): Promise<{ - success: boolean; - timeSpentSeconds: number; - incentiveClaimed: boolean; - }>; - /** Dismiss survey (won't show again) */ - dismissSurvey(surveyId: string): Promise; - /** Check for eligible surveys periodically */ - pollSurveys(intervalMs?: number): () => void; - /** Get cached response for a survey (for offline support) */ - getCachedResponse(surveyId: string): SurveyResponse | null; - /** Save response to cache */ - cacheResponse(surveyId: string, response: Partial): void; -} - -export function createSurveyClient(config: SurveyClientConfig): SurveyClient { - const headers = async () => ({ - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, - 'x-product-id': config.productId, - 'x-platform': config.platform, - 'x-app-version': config.appVersion, - 'x-os-version': config.osVersion, - ...(config.countryCode && { 'x-country-code': config.countryCode }), - ...(config.regionCode && { 'x-region-code': config.regionCode }), - 'x-user-segments': (config.userSegments ?? ['free']).join(','), - ...(config.deviceId && { 'x-device-id': config.deviceId }), - }); - - const request = async (path: string, options?: RequestInit): Promise => { - const res = await fetch(`${config.baseUrl}${path}`, { - ...options, - headers: { - ...(await headers()), - ...(options?.headers || {}), - }, - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`Survey API error: ${res.status} ${err}`); - } - return res.json() as Promise; - }; - - // In-memory cache for offline support - const responseCache = new Map(); - - let pollInterval: ReturnType | null = null; - - return { - async getActiveSurvey() { - return request<{ survey: ActiveSurvey | null }>('/surveys/active'); - }, - - async startSurvey(surveyId: string) { - const result = await request<{ - responseId: string; - startedAt: string; - currentQuestionIndex: number; - answers: Record; - }>(`/surveys/${surveyId}/start`, { method: 'POST' }); - // Cache the initial response - this.cacheResponse(surveyId, { - id: result.responseId, - surveyId, - userId: '', // Will be filled by server - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: result.startedAt, - isComplete: false, - incentiveClaimed: false, - createdAt: result.startedAt, - updatedAt: result.startedAt, - }); - return result; - }, - - async submitAnswer(surveyId: string, questionId: string, answer: QuestionAnswer) { - const result = await request<{ - responseId: string; - currentQuestionIndex: number; - answers: Record; - }>(`/surveys/${surveyId}/response`, { - method: 'POST', - body: JSON.stringify({ questionId, answer }), - }); - // Update cache - const cached = responseCache.get(surveyId); - if (cached) { - cached.answers = result.answers; - cached.currentQuestionIndex = result.currentQuestionIndex; - cached.updatedAt = new Date().toISOString(); - } - return result; - }, - - async completeSurvey(surveyId: string) { - const result = await request<{ - success: boolean; - timeSpentSeconds: number; - incentiveClaimed: boolean; - }>(`/surveys/${surveyId}/complete`, { method: 'POST' }); - // Clear cache on completion - responseCache.delete(surveyId); - return result; - }, - - async dismissSurvey(surveyId: string) { - await request(`/surveys/${surveyId}/dismiss`, { method: 'POST' }); - responseCache.delete(surveyId); - }, - - pollSurveys(intervalMs = 60000) { - if (pollInterval) clearInterval(pollInterval); - pollInterval = setInterval(() => { - this.getActiveSurvey().catch(() => {}); - }, intervalMs); - return () => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }; - }, - - getCachedResponse(surveyId: string) { - return responseCache.get(surveyId) ?? null; - }, - - cacheResponse(surveyId: string, response: Partial) { - const existing = responseCache.get(surveyId); - responseCache.set(surveyId, { - ...existing, - ...response, - } as SurveyResponse); - }, - }; -} - -// ============================================================================= -// Answer Validation -// ============================================================================= - -export function validateAnswer( - question: Question, - answer: QuestionAnswer -): { valid: boolean; error?: string } { - // Type matching - const expectedType = question.type === 'dropdown' ? 'single_choice' : - question.type === 'scale' ? 'rating' : - question.type === 'text_short' || question.type === 'text_long' ? 'text' : - question.type; - - if (answer.type !== expectedType) { - return { valid: false, error: `Expected ${expectedType}, got ${answer.type}` }; - } - - // Value range validation - if (answer.type === 'rating' || answer.type === 'nps') { - if (question.minValue !== undefined && answer.value < question.minValue) { - return { valid: false, error: `Value below minimum ${question.minValue}` }; - } - if (question.maxValue !== undefined && answer.value > question.maxValue) { - return { valid: false, error: `Value above maximum ${question.maxValue}` }; - } - } - - // Text length validation - if (answer.type === 'text') { - if (question.minLength !== undefined && answer.value.length < question.minLength) { - return { valid: false, error: `Text too short (min ${question.minLength})` }; - } - if (question.maxLength !== undefined && answer.value.length > question.maxLength) { - return { valid: false, error: `Text too long (max ${question.maxLength})` }; - } - } - - return { valid: true }; -} - -// ============================================================================= -// React Hook (optional) -// ============================================================================= - -export function createUseSurvey(client: SurveyClient) { - return function useSurvey() { - return { client }; - }; -} diff --git a/vendor/bytelyst/survey-client/tsconfig.json b/vendor/bytelyst/survey-client/tsconfig.json deleted file mode 100644 index 3686f56..0000000 --- a/vendor/bytelyst/survey-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vendor/bytelyst/swift-diagnostics/Package.swift b/vendor/bytelyst/swift-diagnostics/Package.swift deleted file mode 100644 index 1230ec5..0000000 --- a/vendor/bytelyst/swift-diagnostics/Package.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "ByteLystDiagnostics", - platforms: [ - .iOS(.v15), - .macOS(.v13), - .watchOS(.v8), - .tvOS(.v15) - ], - products: [ - .library( - name: "ByteLystDiagnostics", - targets: ["ByteLystDiagnostics"] - ), - ], - dependencies: [], - targets: [ - .target( - name: "ByteLystDiagnostics", - path: "Sources/ByteLystDiagnostics", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency") - ] - ), - .testTarget( - name: "ByteLystDiagnosticsTests", - dependencies: ["ByteLystDiagnostics"], - path: "Tests/ByteLystDiagnosticsTests" - ), - ] -) diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift deleted file mode 100644 index 80a9033..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/ByteLystDiagnostics.swift +++ /dev/null @@ -1,73 +0,0 @@ -/** - * ByteLystDiagnostics - * - * Remote diagnostics and debug tracing client for the ByteLyst ecosystem. - * Provides polling, logging, tracing, network capture, and breadcrumbs for iOS/macOS. - * - * Example usage: - * ```swift - * import ByteLystDiagnostics - * - * // Configure - * let config = DiagnosticsConfiguration( - * productId: "myapp", - * anonymousInstallId: "install_123", - * platform: "ios", - * channel: "ios_app", - * osFamily: "ios", - * appVersion: "1.0.0", - * buildNumber: "100", - * releaseChannel: "stable", - * serverUrl: "https://api.bytelyst.com" - * ) - * - * await DiagnosticsClient.shared.configure(config) - * await DiagnosticsClient.shared.start() - * - * // Auto-instrumented trace - * let result = try await DiagnosticsClient.shared.trace(name: "fetchUser") { - * try await fetchUser() - * } - * - * // Manual breadcrumb - * await DiagnosticsClient.shared.breadcrumb( - * category: "user", - * message: "Tapped submit button", - * data: ["buttonId": AnyCodable("submit")] - * ) - * - * // Manual log - * await DiagnosticsClient.shared.log( - * level: .info, - * message: "User signed in", - * module: "Auth", - * context: ["userId": AnyCodable(userId)] - * ) - * ``` - */ - -// MARK: - Core -@_exported import Foundation - -// Types -public typealias ByteLystDiagnosticsTypes = DiagnosticsSession -public typealias ByteLystDiagnosticsLogLevel = DiagnosticsLogLevel -public typealias ByteLystDiagnosticsSessionStatus = DiagnosticsSessionStatus -public typealias ByteLystDiagnosticsCollectionLevel = DiagnosticsCollectionLevel -public typealias ByteLystDiagnosticsTraceSpan = DiagnosticsTraceSpan -public typealias ByteLystDiagnosticsLogEntry = DiagnosticsLogEntry -public typealias ByteLystDiagnosticsBreadcrumb = DiagnosticsBreadcrumb -public typealias ByteLystDiagnosticsNetworkRequest = DiagnosticsNetworkRequest -public typealias ByteLystDiagnosticsDeviceState = DiagnosticsDeviceState -public typealias ByteLystDiagnosticsIngestBatch = DiagnosticsIngestBatch -public typealias ByteLystDiagnosticsClientState = DiagnosticsClientState -public typealias ByteLystDiagnosticsConfiguration = DiagnosticsConfiguration -public typealias ByteLystDiagnosticsLogger = DiagnosticsLogger -public typealias ByteLystDiagnosticsNoOpLogger = NoOpDiagnosticsLogger -public typealias ByteLystDiagnosticsOSLogger = OSDiagnosticsLogger - -// Errors -public typealias ByteLystDiagnosticsError = DiagnosticsError - -// Version -public let ByteLystDiagnosticsVersion = "0.1.0" diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift deleted file mode 100644 index 6c0f17f..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/BreadcrumbTrail.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Ring buffer for breadcrumbs with fixed max size -public actor BreadcrumbTrail { - private var breadcrumbs: [DiagnosticsBreadcrumb] = [] - private let maxSize: Int - - public init(maxSize: Int = 100) { - self.maxSize = maxSize - } - - /// Add a breadcrumb to the trail - public func add(category: String, message: String, data: [String: AnyCodable]? = nil) { - let breadcrumb = DiagnosticsBreadcrumb( - timestamp: ISO8601DateFormatter().string(from: Date()), - category: category, - message: message, - data: data - ) - - breadcrumbs.append(breadcrumb) - - // Evict oldest if over limit - if breadcrumbs.count > maxSize { - breadcrumbs.removeFirst() - } - } - - /// Get all breadcrumbs (oldest first) - public func getAll() -> [DiagnosticsBreadcrumb] { - breadcrumbs - } - - /// Get last N breadcrumbs - public func getLast(_ n: Int) -> [DiagnosticsBreadcrumb] { - Array(breadcrumbs.suffix(n)) - } - - /// Get most recent breadcrumb - public func getMostRecent() -> DiagnosticsBreadcrumb? { - breadcrumbs.last - } - - /// Clear all breadcrumbs - public func clear() { - breadcrumbs.removeAll() - } - - /// Get current size - public func size() -> Int { - breadcrumbs.count - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift deleted file mode 100644 index 2f7ef6a..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Configuration.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -/// Client configuration -public struct DiagnosticsConfiguration: Sendable { - public let productId: String - public let userId: String? - public let anonymousInstallId: String - public let platform: String - public let channel: String - public let osFamily: String - public let appVersion: String - public let buildNumber: String - public let releaseChannel: String - public let serverUrl: String - public let pollIntervalMs: Int - public let maxBreadcrumbs: Int - public let captureConsole: Bool - public let captureErrors: Bool - public let captureNetwork: Bool - public let getAuthToken: (@Sendable () async throws -> String)? - - public init( - productId: String, - userId: String? = nil, - anonymousInstallId: String, - platform: String, - channel: String, - osFamily: String, - appVersion: String, - buildNumber: String, - releaseChannel: String, - serverUrl: String, - pollIntervalMs: Int = 5000, - maxBreadcrumbs: Int = 100, - captureConsole: Bool = true, - captureErrors: Bool = true, - captureNetwork: Bool = true, - getAuthToken: (@Sendable () async throws -> String)? = nil - ) { - self.productId = productId - self.userId = userId - self.anonymousInstallId = anonymousInstallId - self.platform = platform - self.channel = channel - self.osFamily = osFamily - self.appVersion = appVersion - self.buildNumber = buildNumber - self.releaseChannel = releaseChannel - self.serverUrl = serverUrl - self.pollIntervalMs = pollIntervalMs - self.maxBreadcrumbs = maxBreadcrumbs - self.captureConsole = captureConsole - self.captureErrors = captureErrors - self.captureNetwork = captureNetwork - self.getAuthToken = getAuthToken - } -} - -/// Logger protocol -public protocol DiagnosticsLogger: Sendable { - func debug(_ message: String, metadata: [String: any Sendable]?) - func info(_ message: String, metadata: [String: any Sendable]?) - func warn(_ message: String, metadata: [String: any Sendable]?) - func error(_ message: String, metadata: [String: any Sendable]?) -} - -/// Default no-op logger -public struct NoOpDiagnosticsLogger: DiagnosticsLogger { - public init() {} - public func debug(_ message: String, metadata: [String: any Sendable]?) {} - public func info(_ message: String, metadata: [String: any Sendable]?) {} - public func warn(_ message: String, metadata: [String: any Sendable]?) {} - public func error(_ message: String, metadata: [String: any Sendable]?) {} -} - -/// OSLog-based logger -public struct OSDiagnosticsLogger: DiagnosticsLogger { - private let subsystem: String - private let category: String - - public init(subsystem: String = "com.bytelyst.diagnostics", category: String = "DiagnosticsClient") { - self.subsystem = subsystem - self.category = category - } - - public func debug(_ message: String, metadata: [String: any Sendable]?) { - #if DEBUG - print("[DEBUG] \(message)") - #endif - } - - public func info(_ message: String, metadata: [String: any Sendable]?) { - print("[INFO] \(message)") - } - - public func warn(_ message: String, metadata: [String: any Sendable]?) { - print("[WARN] \(message)") - } - - public func error(_ message: String, metadata: [String: any Sendable]?) { - print("[ERROR] \(message)") - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift deleted file mode 100644 index fd16053..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/DiagnosticsClient.swift +++ /dev/null @@ -1,397 +0,0 @@ -import Foundation - -/// Thread-safe diagnostics client using Swift actors -public actor DiagnosticsClient { - public static let shared = DiagnosticsClient() - - private var configuration: DiagnosticsConfiguration? - private var logger: DiagnosticsLogger = NoOpDiagnosticsLogger() - private var state: DiagnosticsClientState = .idle - private var breadcrumbs = BreadcrumbTrail(maxSize: 100) - private var logBuffer: [DiagnosticsLogEntry] = [] - private var traceBuffer: [DiagnosticsTraceSpan] = [] - private var networkBuffer: [DiagnosticsNetworkRequest] = [] - private var pollTimer: Timer? - private var flushTask: Task? - private var lastEtag: String? - private var networkInterceptor: NetworkInterceptor? - - // MARK: - Singleton - - private init() {} - - // MARK: - Configuration - - public func configure(_ config: DiagnosticsConfiguration, logger: DiagnosticsLogger? = nil) { - self.configuration = config - self.logger = logger ?? NoOpDiagnosticsLogger() - self.breadcrumbs = BreadcrumbTrail(maxSize: config.maxBreadcrumbs) - } - - // MARK: - Lifecycle - - public func start() async { - guard case .idle = state else { - await logger.warn("[diagnostics] Already started", metadata: nil) - return - } - - guard let config = configuration else { - await logger.error("[diagnostics] Not configured", metadata: nil) - state = .error(DiagnosticsError.notConfigured) - return - } - - await logger.info("[diagnostics] Starting diagnostics client", metadata: nil) - state = .polling(session: nil) - - // Initial poll - await pollForSession() - - // Setup network capture if enabled - if config.captureNetwork { - setupNetworkCapture() - } - - // Start flush timer (every 30 seconds) - startFlushTimer() - - await breadcrumbs.add(category: "diagnostics", message: "Client started") - } - - public func stop() async { - await logger.info("[diagnostics] Stopping diagnostics client", metadata: nil) - - pollTimer?.invalidate() - pollTimer = nil - - flushTask?.cancel() - flushTask = nil - - networkInterceptor?.stop() - networkInterceptor = nil - - // Final flush - await flush() - - state = .idle - await breadcrumbs.add(category: "diagnostics", message: "Client stopped") - } - - // MARK: - State - - public func isSessionActive() -> Bool { - if case .active = state { - return true - } - return false - } - - public func getCurrentSession() -> DiagnosticsSession? { - switch state { - case .active(let session), .polling(let session): - return session - default: - return nil - } - } - - public func getState() -> DiagnosticsClientState { - state - } - - // MARK: - Logging - - public func log( - level: DiagnosticsLogLevel, - message: String, - module: String = "unknown", - file: String? = nil, - line: Int? = nil, - function: String? = nil, - context: [String: AnyCodable] = [:], - correlationId: String? = nil - ) async { - let entry = DiagnosticsLogEntry( - level: level, - message: message, - timestamp: ISO8601DateFormatter().string(from: Date()), - module: module, - file: file, - line: line, - function: function, - context: context, - correlationId: correlationId - ) - - logBuffer.append(entry) - await breadcrumbs.add( - category: "log", - message: "[\(level.rawValue.uppercased())] \(String(message.prefix(100)))", - data: ["level": AnyCodable(level.rawValue)] - ) - - // Auto-flush on fatal - if level == .fatal { - Task { await flush() } - } - } - - // MARK: - Tracing - - public func trace( - name: String, - operation: () async throws -> T - ) async rethrows -> T { - let spanId = generateId() - let startTime = ISO8601DateFormatter().string(from: Date()) - - await breadcrumbs.add( - category: "trace", - message: "Starting: \(name)", - data: ["spanId": AnyCodable(spanId)] - ) - - do { - let result = try await operation() - let endTime = ISO8601DateFormatter().string(from: Date()) - let start = ISO8601DateFormatter().date(from: startTime) ?? Date() - let end = ISO8601DateFormatter().date(from: endTime) ?? Date() - let durationMs = end.timeIntervalSince(start) * 1000 - - let span = DiagnosticsTraceSpan( - spanId: spanId, - name: name, - startTime: startTime, - endTime: endTime, - durationMs: durationMs, - attributes: [:], - status: .ok - ) - traceBuffer.append(span) - - await breadcrumbs.add( - category: "trace", - message: "Completed: \(name)", - data: [ - "spanId": AnyCodable(spanId), - "durationMs": AnyCodable(durationMs) - ] - ) - - return result - } catch { - let endTime = ISO8601DateFormatter().string(from: Date()) - let start = ISO8601DateFormatter().date(from: startTime) ?? Date() - let end = ISO8601DateFormatter().date(from: endTime) ?? Date() - let durationMs = end.timeIntervalSince(start) * 1000 - - let span = DiagnosticsTraceSpan( - spanId: spanId, - name: name, - startTime: startTime, - endTime: endTime, - durationMs: durationMs, - attributes: [:], - status: .error, - statusMessage: error.localizedDescription - ) - traceBuffer.append(span) - - await breadcrumbs.add( - category: "trace", - message: "Failed: \(name)", - data: [ - "spanId": AnyCodable(spanId), - "error": AnyCodable(error.localizedDescription) - ] - ) - - throw error - } - } - - // MARK: - Breadcrumbs - - public func breadcrumb( - category: String, - message: String, - data: [String: AnyCodable]? = nil - ) async { - await breadcrumbs.add(category: category, message: message, data: data) - } - - public func getBreadcrumbs() -> [DiagnosticsBreadcrumb] { - breadcrumbs.getAll() - } - - // MARK: - Private - - private func pollForSession() async { - guard let config = configuration else { return } - - var request = URLRequest( - url: URL(string: "\(config.serverUrl)/api/diagnostics/config?productId=\(config.productId)&installId=\(config.anonymousInstallId)")! - ) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - if let etag = lastEtag { - request.setValue(etag, forHTTPHeaderField: "If-None-Match") - } - - if let getToken = config.getAuthToken { - do { - let token = try await getToken() - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } catch { - await logger.error("[diagnostics] Failed to get auth token", metadata: ["error": error.localizedDescription]) - } - } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw DiagnosticsError.invalidResponse - } - - if httpResponse.statusCode == 304 { - // No change - return - } - - guard httpResponse.statusCode == 200 else { - throw DiagnosticsError.httpError(statusCode: httpResponse.statusCode) - } - - // Store ETag - if let etag = httpResponse.allHeaderFields["Etag"] as? String { - lastEtag = etag - } - - let decoder = JSONDecoder() - let session = try? decoder.decode(DiagnosticsSession.self, from: data) - - if let session = session, session.status == .active { - if case .active = state { - // Already active, just update session - } else { - await logger.info("[diagnostics] Session activated", metadata: ["sessionId": session.id]) - await breadcrumbs.add(category: "diagnostics", message: "Session activated", data: ["sessionId": AnyCodable(session.id)]) - } - state = .active(session: session) - } else { - if case .active = state { - await logger.info("[diagnostics] Session ended", metadata: nil) - await breadcrumbs.add(category: "diagnostics", message: "Session ended") - } - state = .polling(session: nil) - } - } catch { - await logger.error("[diagnostics] Failed to poll for session", metadata: ["error": error.localizedDescription]) - state = .error(error) - } - } - - private func flush() async { - guard let session = getCurrentSession() else { - logBuffer.removeAll() - traceBuffer.removeAll() - networkBuffer.removeAll() - return - } - - let batch = DiagnosticsIngestBatch( - sessionId: session.id, - traces: traceBuffer.isEmpty ? nil : traceBuffer, - logs: logBuffer.isEmpty ? nil : logBuffer, - breadcrumbs: breadcrumbs.getAll().isEmpty ? nil : breadcrumbs.getAll(), - network: networkBuffer.isEmpty ? nil : networkBuffer - ) - - // Clear buffers - logBuffer.removeAll() - traceBuffer.removeAll() - networkBuffer.removeAll() - breadcrumbs.clear() - - guard let config = configuration else { return } - - var request = URLRequest( - url: URL(string: "\(config.serverUrl)/api/diagnostics/ingest")! - ) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let getToken = config.getAuthToken { - do { - let token = try await getToken() - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } catch { - await logger.error("[diagnostics] Failed to get auth token for flush", metadata: ["error": error.localizedDescription]) - } - } - - do { - let encoder = JSONEncoder() - request.httpBody = try encoder.encode(batch) - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw DiagnosticsError.flushFailed - } - - await logger.debug("[diagnostics] Flushed batch", metadata: [ - "logs": batch.logs?.count ?? 0, - "traces": batch.traces?.count ?? 0, - "network": batch.network?.count ?? 0 - ]) - } catch { - await logger.error("[diagnostics] Failed to flush batch", metadata: ["error": error.localizedDescription]) - - // Put items back in buffers for retry - if let logs = batch.logs { logBuffer.append(contentsOf: logs) } - if let traces = batch.traces { traceBuffer.append(contentsOf: traces) } - if let network = batch.network { networkBuffer.append(contentsOf: network) } - } - } - - private func startFlushTimer() { - flushTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds - await flush() - } - } - } - - private func setupNetworkCapture() { - networkInterceptor = NetworkInterceptor { [weak self] request in - Task { [weak self] in - await self?.networkBuffer.append(request) - } - } - networkInterceptor?.start() - } - - private func generateId() -> String { - "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" - } -} - -/// Client state enum -public enum DiagnosticsClientState: Sendable { - case idle - case polling(session: DiagnosticsSession?) - case active(session: DiagnosticsSession) - case error(Error) -} - -/// Diagnostics errors -public enum DiagnosticsError: Error, Sendable { - case notConfigured - case invalidResponse - case httpError(statusCode: Int) - case flushFailed -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift deleted file mode 100644 index cf2038d..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Core/Types.swift +++ /dev/null @@ -1,335 +0,0 @@ -import Foundation - -/// Log severity levels (matches syslog/OpenTelemetry) -public enum DiagnosticsLogLevel: String, Codable, Sendable { - case debug - case info - case warn - case error - case fatal -} - -/// Session status from the server -public enum DiagnosticsSessionStatus: String, Codable, Sendable { - case pending - case active - case paused - case completed - case cancelled -} - -/// Collection level determines verbosity of captured data -public enum DiagnosticsCollectionLevel: String, Codable, Sendable { - case standard - case debug - case trace -} - -/// Diagnostic session configuration from server -public struct DiagnosticsSession: Codable, Sendable { - public let id: String - public let productId: String - public let status: DiagnosticsSessionStatus - public let collectionLevel: DiagnosticsCollectionLevel - public let captureLogs: Bool - public let captureNetwork: Bool - public let captureScreenshots: Bool - public let screenshotOnError: Bool - public let maxDurationMinutes: Int - public let createdAt: String - public let expiresAt: String - - public init( - id: String, - productId: String, - status: DiagnosticsSessionStatus, - collectionLevel: DiagnosticsCollectionLevel, - captureLogs: Bool, - captureNetwork: Bool, - captureScreenshots: Bool, - screenshotOnError: Bool, - maxDurationMinutes: Int, - createdAt: String, - expiresAt: String - ) { - self.id = id - self.productId = productId - self.status = status - self.collectionLevel = collectionLevel - self.captureLogs = captureLogs - self.captureNetwork = captureNetwork - self.captureScreenshots = captureScreenshots - self.screenshotOnError = screenshotOnError - self.maxDurationMinutes = maxDurationMinutes - self.createdAt = createdAt - self.expiresAt = expiresAt - } -} - -/// Span kind for OpenTelemetry compatibility -public enum DiagnosticsSpanKind: String, Codable, Sendable { - case internal - case server - case client - case producer - case consumer -} - -/// Span status -public enum DiagnosticsSpanStatus: String, Codable, Sendable { - case ok - case error - case unset -} - -/// OpenTelemetry-compatible trace span -public struct DiagnosticsTraceSpan: Codable, Sendable { - public let spanId: String - public let parentId: String? - public let name: String - public let kind: DiagnosticsSpanKind? - public let startTime: String - public let endTime: String? - public let durationMs: Double? - public let attributes: [String: AnyCodable] - public let status: DiagnosticsSpanStatus - public let statusMessage: String? - - public init( - spanId: String, - parentId: String? = nil, - name: String, - kind: DiagnosticsSpanKind? = nil, - startTime: String, - endTime: String? = nil, - durationMs: Double? = nil, - attributes: [String: AnyCodable] = [:], - status: DiagnosticsSpanStatus, - statusMessage: String? = nil - ) { - self.spanId = spanId - self.parentId = parentId - self.name = name - self.kind = kind - self.startTime = startTime - self.endTime = endTime - self.durationMs = durationMs - self.attributes = attributes - self.status = status - self.statusMessage = statusMessage - } -} - -/// Structured log entry -public struct DiagnosticsLogEntry: Codable, Sendable { - public let level: DiagnosticsLogLevel - public let message: String - public let timestamp: String - public let module: String - public let file: String? - public let line: Int? - public let function: String? - public let context: [String: AnyCodable] - public let correlationId: String? - - public init( - level: DiagnosticsLogLevel, - message: String, - timestamp: String, - module: String, - file: String? = nil, - line: Int? = nil, - function: String? = nil, - context: [String: AnyCodable] = [:], - correlationId: String? = nil - ) { - self.level = level - self.message = message - self.timestamp = timestamp - self.module = module - self.file = file - self.line = line - self.function = function - self.context = context - self.correlationId = correlationId - } -} - -/// Breadcrumb for timeline navigation -public struct DiagnosticsBreadcrumb: Codable, Sendable { - public let timestamp: String - public let category: String - public let message: String - public let data: [String: AnyCodable]? - - public init( - timestamp: String, - category: String, - message: String, - data: [String: AnyCodable]? = nil - ) { - self.timestamp = timestamp - self.category = category - self.message = message - self.data = data - } -} - -/// Network request/response capture -public struct DiagnosticsNetworkRequest: Codable, Sendable { - public let id: String - public let url: String - public let method: String - public let requestHeaders: [String: String] - public let requestBody: String? - public let status: Int? - public let responseHeaders: [String: String]? - public let responseBody: String? - public let startTime: String - public let endTime: String? - public let durationMs: Double? - public let error: String? - - public init( - id: String, - url: String, - method: String, - requestHeaders: [String: String] = [:], - requestBody: String? = nil, - status: Int? = nil, - responseHeaders: [String: String]? = nil, - responseBody: String? = nil, - startTime: String, - endTime: String? = nil, - durationMs: Double? = nil, - error: String? = nil - ) { - self.id = id - self.url = url - self.method = method - self.requestHeaders = requestHeaders - self.requestBody = requestBody - self.status = status - self.responseHeaders = responseHeaders - self.responseBody = responseBody - self.startTime = startTime - self.endTime = endTime - self.durationMs = durationMs - self.error = error - } -} - -/// Device state snapshot -public struct DiagnosticsDeviceState: Codable, Sendable { - public let memoryMB: Int? - public let batteryLevel: Double? - public let isCharging: Bool? - public let storageMB: Int? - public let networkType: String? - public let isOnline: Bool - public let thermalState: DiagnosticsThermalState? - - public init( - memoryMB: Int? = nil, - batteryLevel: Double? = nil, - isCharging: Bool? = nil, - storageMB: Int? = nil, - networkType: String? = nil, - isOnline: Bool, - thermalState: DiagnosticsThermalState? = nil - ) { - self.memoryMB = memoryMB - self.batteryLevel = batteryLevel - self.isCharging = isCharging - self.storageMB = storageMB - self.networkType = networkType - self.isOnline = isOnline - self.thermalState = thermalState - } -} - -/// Thermal state -public enum DiagnosticsThermalState: String, Codable, Sendable { - case nominal - case fair - case serious - case critical -} - -/// Client state -public enum DiagnosticsClientState: Sendable { - case idle - case polling(session: DiagnosticsSession?) - case active(session: DiagnosticsSession) - case error(Error) -} - -/// Ingest batch for sending to server -public struct DiagnosticsIngestBatch: Codable, Sendable { - public let sessionId: String - public let traces: [DiagnosticsTraceSpan]? - public let logs: [DiagnosticsLogEntry]? - public let breadcrumbs: [DiagnosticsBreadcrumb]? - public let network: [DiagnosticsNetworkRequest]? - - public init( - sessionId: String, - traces: [DiagnosticsTraceSpan]? = nil, - logs: [DiagnosticsLogEntry]? = nil, - breadcrumbs: [DiagnosticsBreadcrumb]? = nil, - network: [DiagnosticsNetworkRequest]? = nil - ) { - self.sessionId = sessionId - self.traces = traces - self.logs = logs - self.breadcrumbs = breadcrumbs - self.network = network - } -} - -/// Type-erased Codable wrapper for dictionary values -public struct AnyCodable: Codable, Sendable { - private let value: any Codable - - public init(_ value: any Codable) { - self.value = value - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let boolValue = try? container.decode(Bool.self) { - value = boolValue - } else if let intValue = try? container.decode(Int.self) { - value = intValue - } else if let doubleValue = try? container.decode(Double.self) { - value = doubleValue - } else if let stringValue = try? container.decode(String.self) { - value = stringValue - } else if let arrayValue = try? container.decode([AnyCodable].self) { - value = arrayValue - } else if let dictValue = try? container.decode([String: AnyCodable].self) { - value = dictValue - } else { - value = "" - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if let boolValue = value as? Bool { - try container.encode(boolValue) - } else if let intValue = value as? Int { - try container.encode(intValue) - } else if let doubleValue = value as? Double { - try container.encode(doubleValue) - } else if let stringValue = value as? String { - try container.encode(stringValue) - } else if let arrayValue = value as? [AnyCodable] { - try container.encode(arrayValue) - } else if let dictValue = value as? [String: AnyCodable] { - try container.encode(dictValue) - } else { - try container.encode("") - } - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift deleted file mode 100644 index dcd60d6..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Device/DeviceState.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation -import UIKit -#if os(iOS) -import SystemConfiguration -#endif - -/// Device state collector -public struct DeviceStateCollector { - - /// Collect current device state - public static func collect() -> DiagnosticsDeviceState { - #if os(iOS) - return DiagnosticsDeviceState( - memoryMB: getMemoryUsage(), - batteryLevel: getBatteryLevel(), - isCharging: getIsCharging(), - storageMB: getStorageUsage(), - networkType: getNetworkType(), - isOnline: getIsOnline(), - thermalState: getThermalState() - ) - #elseif os(macOS) - return DiagnosticsDeviceState( - memoryMB: getMemoryUsage(), - batteryLevel: nil, - isCharging: nil, - storageMB: nil, - networkType: nil, - isOnline: getIsOnline(), - thermalState: nil - ) - #else - return DiagnosticsDeviceState( - memoryMB: nil, - batteryLevel: nil, - isCharging: nil, - storageMB: nil, - networkType: nil, - isOnline: true, - thermalState: nil - ) - #endif - } - - #if os(iOS) - private static func getMemoryUsage() -> Int? { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) - } - } - - guard kerr == KERN_SUCCESS else { return nil } - return Int(info.resident_size / 1024 / 1024) - } - - private static func getBatteryLevel() -> Double? { - UIDevice.current.isBatteryMonitoringEnabled = true - return Double(UIDevice.current.batteryLevel) - } - - private static func getIsCharging() -> Bool? { - UIDevice.current.isBatteryMonitoringEnabled = true - return UIDevice.current.batteryState == .charging - } - - private static func getStorageUsage() -> Int? { - do { - let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) - if let totalSize = attributes[.systemSize] as? NSNumber, - let freeSize = attributes[.systemFreeSize] as? NSNumber { - let usedSize = totalSize.int64Value - freeSize.int64Value - return Int(usedSize / 1024 / 1024) - } - } catch { - return nil - } - return nil - } - - private static func getNetworkType() -> String? { - // Simplified - would need more complex reachability check for actual implementation - if getIsOnline() { - return "wifi" // Default assumption - } - return "offline" - } - - private static func getThermalState() -> DiagnosticsThermalState? { - switch ProcessInfo.processInfo.thermalState { - case .nominal: - return .nominal - case .fair: - return .fair - case .serious: - return .serious - case .critical: - return .critical - @unknown default: - return nil - } - } - #endif - - private static func getIsOnline() -> Bool { - #if os(iOS) || os(macOS) - var zeroAddress = sockaddr_in() - zeroAddress.sin_len = UInt8(MemoryLayout.size) - zeroAddress.sin_family = sa_family_t(AF_INET) - - guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - SCNetworkReachabilityCreateWithAddress(nil, $0) - } - }) else { - return false - } - - var flags: SCNetworkReachabilityFlags = [] - if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { - return false - } - - let isReachable = flags.contains(.reachable) - let needsConnection = flags.contains(.connectionRequired) - - return isReachable && !needsConnection - #else - return true - #endif - } -} - -// MARK: - Connectivity Monitoring - -#if os(iOS) || os(macOS) -import SystemConfiguration - -/// Monitor network connectivity changes -public final class ConnectivityMonitor { - private var reachability: SCNetworkReachability? - private var callback: ((Bool) -> Void)? - - public init() { - var zeroAddress = sockaddr_in() - zeroAddress.sin_len = UInt8(MemoryLayout.size) - zeroAddress.sin_family = sa_family_t(AF_INET) - - reachability = withUnsafePointer(to: &zeroAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - SCNetworkReachabilityCreateWithAddress(nil, $0) - } - } - } - - public func startMonitoring(callback: @escaping (Bool) -> Void) { - self.callback = callback - - guard let reachability = reachability else { return } - - let context = SCNetworkReachabilityContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - SCNetworkReachabilitySetCallback(reachability, { (_, flags, info) in - guard let info = info else { return } - let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() - - let isReachable = flags.contains(.reachable) - let needsConnection = flags.contains(.connectionRequired) - let isConnected = isReachable && !needsConnection - - monitor.callback?(isConnected) - }, &context) - - SCNetworkReachabilityScheduleWithRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) - } - - public func stopMonitoring() { - guard let reachability = reachability else { return } - SCNetworkReachabilityUnscheduleFromRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) - } -} -#endif diff --git a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift b/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift deleted file mode 100644 index b7fd71b..0000000 --- a/vendor/bytelyst/swift-diagnostics/Sources/ByteLystDiagnostics/Network/NetworkInterceptor.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation - -/// Network interceptor using URLProtocol for automatic capture -public final class NetworkInterceptor: URLProtocol { - public static var onRequest: ((DiagnosticsNetworkRequest) -> Void)? - private static let shared = NetworkInterceptor() - private var requestId: String? - private var startTime: Date? - private var request: URLRequest? - - private lazy var session: URLSession = { - let config = URLSessionConfiguration.default - return URLSession(configuration: config, delegate: self, delegateQueue: nil) - }() - - private var dataTask: URLSessionDataTask? - - // MARK: - URLProtocol Overrides - - public override class func canInit(with request: URLRequest) -> Bool { - // Don't intercept our own requests - if request.value(forHTTPHeaderField: "X-Diagnostics-Intercepted") != nil { - return false - } - return true - } - - public override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request - } - - public override func startLoading() { - requestId = "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))" - startTime = Date() - - var newRequest = request - newRequest?.setValue("true", forHTTPHeaderField: "X-Diagnostics-Intercepted") - - self.request = newRequest - - dataTask = session.dataTask(with: newRequest!) - dataTask?.resume() - } - - public override func stopLoading() { - dataTask?.cancel() - } - - // MARK: - Public API - - public static func start() { - URLProtocol.registerClass(NetworkInterceptor.self) - } - - public static func stop() { - URLProtocol.unregisterClass(NetworkInterceptor.self) - } - - public func start(with handler: @escaping (DiagnosticsNetworkRequest) -> Void) { - NetworkInterceptor.onRequest = handler - NetworkInterceptor.start() - } - - public func stopInterceptor() { - NetworkInterceptor.stop() - NetworkInterceptor.onRequest = nil - } -} - -// MARK: - URLSessionDataDelegate - -extension NetworkInterceptor: URLSessionDataDelegate { - public func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - client?.urlProtocol(self, didLoad: data) - } - - public func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - guard let request = request, - let requestId = requestId, - let startTime = startTime else { - return - } - - let endTime = Date() - let durationMs = endTime.timeIntervalSince(startTime) * 1000 - - var networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: request.url?.absoluteString ?? "", - method: request.httpMethod ?? "GET", - requestHeaders: request.allHTTPHeaderFields?.mapValues { value in - NetworkInterceptor.sanitizeHeader(value, key: "") - } ?? [:], - requestBody: request.httpBody.flatMap { String(data: $0, encoding: .utf8) }, - startTime: ISO8601DateFormatter().string(from: startTime), - endTime: ISO8601DateFormatter().string(from: endTime), - durationMs: durationMs - ) - - if let error = error { - networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: networkRequest.url, - method: networkRequest.method, - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - status: nil, - responseHeaders: nil, - responseBody: nil, - startTime: networkRequest.startTime, - endTime: networkRequest.endTime, - durationMs: networkRequest.durationMs, - error: error.localizedDescription - ) - } else if let response = task.response as? HTTPURLResponse { - networkRequest = DiagnosticsNetworkRequest( - id: requestId, - url: networkRequest.url, - method: networkRequest.method, - requestHeaders: networkRequest.requestHeaders, - requestBody: networkRequest.requestBody, - status: response.statusCode, - responseHeaders: response.allHeaderFields as? [String: String], - responseBody: nil, // Don't capture response body (too large) - startTime: networkRequest.startTime, - endTime: networkRequest.endTime, - durationMs: networkRequest.durationMs, - error: nil - ) - } - - NetworkInterceptor.onRequest?(networkRequest) - - if let error = error { - client?.urlProtocol(self, didFailWithError: error) - } else { - client?.urlProtocolDidFinishLoading(self) - } - } - - private static func sanitizeHeader(_ value: String, key: String) -> String { - let sensitivePatterns = ["authorization", "cookie", "token", "api-key"] - let lowerKey = key.lowercased() - - for pattern in sensitivePatterns { - if lowerKey.contains(pattern) { - return "[REDACTED]" - } - } - return value - } -} diff --git a/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift b/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift deleted file mode 100644 index 63f1263..0000000 --- a/vendor/bytelyst/swift-diagnostics/Tests/ByteLystDiagnosticsTests/DiagnosticsClientTests.swift +++ /dev/null @@ -1,296 +0,0 @@ -import XCTest -@testable import ByteLystDiagnostics - -final class DiagnosticsClientTests: XCTestCase { - - override func setUp() { - super.setUp() - // Reset to initial state - Task { - // Client is actor, need to await - } - } - - // MARK: - Singleton Tests - - func testSharedInstance() { - let client1 = DiagnosticsClient.shared - let client2 = DiagnosticsClient.shared - XCTAssertTrue(client1 === client2, "Shared instance should be singleton") - } - - // MARK: - Configuration Tests - - func testConfiguration() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - let state = await DiagnosticsClient.shared.getState() - XCTAssertEqual(state, .idle) - } - - // MARK: - Session State Tests - - func testInitialSessionState() async { - let isActive = await DiagnosticsClient.shared.isSessionActive() - XCTAssertFalse(isActive) - - let session = await DiagnosticsClient.shared.getCurrentSession() - XCTAssertNil(session) - } - - // MARK: - Breadcrumb Tests - - func testBreadcrumbAdding() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - await DiagnosticsClient.shared.breadcrumb( - category: "navigation", - message: "Page loaded", - data: ["path": AnyCodable("/home")] - ) - - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertEqual(breadcrumbs.count, 1) - XCTAssertEqual(breadcrumbs.first?.category, "navigation") - XCTAssertEqual(breadcrumbs.first?.message, "Page loaded") - } - - func testMultipleBreadcrumbs() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - for i in 1...5 { - await DiagnosticsClient.shared.breadcrumb( - category: "test", - message: "Message \(i)" - ) - } - - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertEqual(breadcrumbs.count, 5) - } - - // MARK: - Tracing Tests - - func testSuccessfulTrace() async throws { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - let result = try await DiagnosticsClient.shared.trace(name: "test-operation") { - return 42 - } - - XCTAssertEqual(result, 42) - } - - func testFailingTrace() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - do { - _ = try await DiagnosticsClient.shared.trace(name: "failing-operation") { - throw TestError.test - } - XCTFail("Should have thrown") - } catch { - // Expected - XCTAssertTrue(error is TestError) - } - } - - // MARK: - Logging Tests - - func testLogAdding() async { - let config = DiagnosticsConfiguration( - productId: "test-app", - anonymousInstallId: "install-123", - platform: "ios", - channel: "ios_app", - osFamily: "ios", - appVersion: "1.0.0", - buildNumber: "100", - releaseChannel: "beta", - serverUrl: "https://api.test.com" - ) - - await DiagnosticsClient.shared.configure(config) - - await DiagnosticsClient.shared.log( - level: .info, - message: "Test message", - module: "TestModule" - ) - - // Log is buffered, no immediate assertion possible - // But breadcrumb should be created - let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs() - XCTAssertTrue(breadcrumbs.contains { $0.category == "log" }) - } - - // MARK: - Types Tests - - func testDiagnosticsSessionEncoding() throws { - let session = DiagnosticsSession( - id: "ds_test123", - productId: "test-app", - status: .active, - collectionLevel: .debug, - captureLogs: true, - captureNetwork: true, - captureScreenshots: false, - screenshotOnError: true, - maxDurationMinutes: 60, - createdAt: "2026-03-03T12:00:00Z", - expiresAt: "2026-03-03T13:00:00Z" - ) - - let encoder = JSONEncoder() - let data = try encoder.encode(session) - - let decoder = JSONDecoder() - let decoded = try decoder.decode(DiagnosticsSession.self, from: data) - - XCTAssertEqual(decoded.id, session.id) - XCTAssertEqual(decoded.status, session.status) - XCTAssertEqual(decoded.collectionLevel, session.collectionLevel) - } - - func testLogEntryCreation() { - let entry = DiagnosticsLogEntry( - level: .error, - message: "Something went wrong", - timestamp: "2026-03-03T12:00:00Z", - module: "TestModule", - file: "Test.swift", - line: 42, - function: "testFunction", - context: ["key": AnyCodable("value")], - correlationId: "corr-123" - ) - - XCTAssertEqual(entry.level, .error) - XCTAssertEqual(entry.message, "Something went wrong") - XCTAssertEqual(entry.module, "TestModule") - XCTAssertEqual(entry.line, 42) - XCTAssertEqual(entry.correlationId, "corr-123") - } - - func testTraceSpanCreation() { - let span = DiagnosticsTraceSpan( - spanId: "span-123", - parentId: "parent-456", - name: "test-span", - kind: .internal, - startTime: "2026-03-03T12:00:00Z", - endTime: "2026-03-03T12:00:01Z", - durationMs: 1000, - attributes: ["key": AnyCodable("value")], - status: .ok, - statusMessage: nil - ) - - XCTAssertEqual(span.spanId, "span-123") - XCTAssertEqual(span.parentId, "parent-456") - XCTAssertEqual(span.name, "test-span") - XCTAssertEqual(span.status, .ok) - } - - func testBreadcrumbCreation() { - let breadcrumb = DiagnosticsBreadcrumb( - timestamp: "2026-03-03T12:00:00Z", - category: "navigation", - message: "User tapped button", - data: ["buttonId": AnyCodable("submit")] - ) - - XCTAssertEqual(breadcrumb.category, "navigation") - XCTAssertEqual(breadcrumb.message, "User tapped button") - XCTAssertNotNil(breadcrumb.data) - } - - func testAnyCodableEncoding() throws { - let value = AnyCodable("test-string") - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - // Should not throw - XCTAssertFalse(data.isEmpty) - } - - func testAnyCodableIntEncoding() throws { - let value = AnyCodable(42) - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - XCTAssertFalse(data.isEmpty) - } - - func testAnyCodableBoolEncoding() throws { - let value = AnyCodable(true) - let encoder = JSONEncoder() - let data = try encoder.encode(value) - - XCTAssertFalse(data.isEmpty) - } -} - -enum TestError: Error { - case test -} diff --git a/vendor/bytelyst/swift-platform-sdk/Package.swift b/vendor/bytelyst/swift-platform-sdk/Package.swift deleted file mode 100644 index 210cceb..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Package.swift +++ /dev/null @@ -1,31 +0,0 @@ -// swift-tools-version: 5.9 -// ByteLystPlatformSDK — Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. -// Lives in learning_ai_common_plat so every product app references ONE source of truth. - -import PackageDescription - -let package = Package( - name: "ByteLystPlatformSDK", - platforms: [ - .iOS(.v17), - .watchOS(.v10), - .macOS(.v14), - ], - products: [ - .library( - name: "ByteLystPlatformSDK", - targets: ["ByteLystPlatformSDK"] - ), - ], - targets: [ - .target( - name: "ByteLystPlatformSDK", - path: "Sources" - ), - .testTarget( - name: "ByteLystPlatformSDKTests", - dependencies: ["ByteLystPlatformSDK"], - path: "Tests" - ), - ] -) diff --git a/vendor/bytelyst/swift-platform-sdk/README.md b/vendor/bytelyst/swift-platform-sdk/README.md deleted file mode 100644 index e500e78..0000000 --- a/vendor/bytelyst/swift-platform-sdk/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# ByteLystPlatformSDK - -Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. Eliminates code duplication across products by providing a single source of truth for platform-service integration. - -## What's Inside - -| File | What It Does | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `BLPlatformConfig` | Product-specific configuration (productId, baseURL, bundleId, appGroupId) | -| `BLPlatformClient` | Generic HTTP client with auth injection, x-request-id, timeout | -| `BLKeychain` | Keychain CRUD for secure token storage | -| `BLTelemetryClient` | Telemetry event queue + batch flush (matches `@bytelyst/telemetry-client`) | -| `BLAuthClient` | Auth operations: login, register, refresh, password ops, email verify, delete account (matches `@bytelyst/auth-client`) | -| `BLFeatureFlagClient` | Feature flag polling from platform-service `/api/flags/poll` | -| `BLSyncEngine` | Generic offline-first sync engine with delta pull + batch push | -| `BLBlobClient` | Azure Blob Storage upload via SAS tokens from platform-service | -| `BLKillSwitchClient` | Kill switch check from platform-service (fail-open) | -| `BLLicenseClient` | License key activation + status via platform-service | -| `BLBiometricAuth` | Face ID / Touch ID wrapper (LocalAuthentication) | -| `BLCrashReporter` | MetricKit crash and hang reporting with local storage | -| `BLAuditLogger` | Local rotating JSON audit log for debugging | - -## Usage - -### 1. Add to Xcode Project - -In Xcode: **File → Add Package Dependencies → Add Local...** → select this directory: - -``` -../learning_ai_common_plat/packages/swift-platform-sdk/ -``` - -Or in `Package.swift`: - -```swift -.package(path: "../learning_ai_common_plat/packages/swift-platform-sdk") -``` - -### 2. Configure at App Launch - -```swift -import ByteLystPlatformSDK - -// Create config — one per app -let config = BLPlatformConfig.fromInfoPlist( - productId: "peakpulse", - defaultBaseURL: "https://api.peakpulse.app", - bundleId: "com.saravana.peakpulse" -) - -// Create shared HTTP client -let client = BLPlatformClient(config: config) - -// Create services -let telemetry = BLTelemetryClient(config: config, client: client) -let auth = BLAuthClient(config: config, client: client) -let flags = BLFeatureFlagClient(config: config, client: client) -``` - -### 3. Telemetry - -```swift -telemetry.start() -telemetry.trackEvent("info", module: "session", name: "session_started", metrics: ["elevation": 2450.0]) -telemetry.trackScreen("live_tracking") -// On app background: -telemetry.stop() -``` - -### 4. Auth - -```swift -// Login -let user = try await auth.login(email: "user@example.com", password: "secret") - -// Restore on launch -await auth.restoreSession() - -// Listen for state changes -auth.onAuthStateChanged = { state in - switch state { - case .loggedIn(let user): print("Hello, \(user.displayName)") - case .loggedOut: print("Signed out") - case .error(let msg): print("Auth error: \(msg)") - case .loading: print("Loading...") - } -} -``` - -### 5. Feature Flags - -```swift -flags.start(userId: auth.accessToken != nil ? "user-id" : nil) -if flags.isEnabled("peakpulse.pro_charts") { - // Show Pro charts -} -``` - -### 6. Sync Engine - -```swift -// Implement your product-specific adapter -struct PeakSessionSyncAdapter: BLSyncAdapter { - typealias SyncItem = PeakSessionDTO - - func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [PeakSessionDTO] { - var path = "/api/peak-sessions/sync" - if let since { path += "?since=\(ISO8601DateFormatter().string(from: since))" } - return try await client.request(path: path, responseType: [PeakSessionDTO].self) - } - - func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult { - let body = try JSONEncoder().encode(["items": items.compactMap(\.payload)]) - let (data, _) = try await client.rawRequest(path: "/api/peak-sessions/batch", method: "POST", body: body) - return try JSONDecoder().decode(BLBatchResult.self, from: data) - } -} - -let syncEngine = BLSyncEngine( - config: config, - client: client, - adapter: PeakSessionSyncAdapter() -) -``` - -## Product Apps Using This SDK - -| Product | Repo | Wrappers | Status | -| ---------- | ----------------------------------- | -------- | ----------------------------------- | -| ChronoMind | `learning_ai_clock` | 5 files | ✅ Migrated (Cloud/ + Diagnostics/) | -| LysnrAI | `learning_voice_ai_agent` | 9 files | ✅ Migrated (Auth/ + Util/) | -| MindLyst | `learning_multimodal_memory_agents` | 4 files | ✅ Migrated (Services/) | -| PeakPulse | `learning_ai_peakpulse` | — | New — will use SDK from day one | -| NomGap | `learning_ai_fastgap` | — | React Native — uses TS packages | - -## What This Replaces - -Before this SDK, each iOS app had its own copy of platform integration code: - -| ChronoMind (old) | LysnrAI (old) | MindLyst (old) | SDK (new) | -| --------------------------------- | ------------------------------- | ------------------------------- | --------------------- | -| `KeychainHelper` (53 lines) | `KeychainHelper` (60 lines) | `KeychainHelper` (60 lines) | `BLKeychain` | -| `CMTelemetryService` (139 lines) | `TelemetryService` (288 lines) | `TelemetryService` (139 lines) | `BLTelemetryClient` | -| `CMAuthService` (359 lines) | `AuthService` (421 lines) | `AuthService` (389 lines) | `BLAuthClient` | -| `FeatureFlagService` (72 lines) | `FeatureFlagService` (71 lines) | `FeatureFlagService` (72 lines) | `BLFeatureFlagClient` | -| `CrashReporter` (153 lines) | — | — | `BLCrashReporter` | -| — | `BlobService` (118 lines) | — | `BLBlobClient` | -| — | `KillSwitchService` (48 lines) | — | `BLKillSwitchClient` | -| — | `LicenseService` (135 lines) | — | `BLLicenseClient` | -| — | `BiometricAuth` (65 lines) | — | `BLBiometricAuth` | -| — | `AuditLogger` (70 lines) | — | `BLAuditLogger` | -| `PlatformSyncManager` (450 lines) | Various sync files | — | `BLSyncEngine` | - -Total duplicated code eliminated: **~2,600+ lines across 3 product apps**. - -## Design Decisions - -1. **No `@MainActor`** — the SDK is thread-safe via NSLock. Product apps can wrap in `@MainActor` at the view model layer. -2. **No singletons** — product apps own the lifecycle. Create instances at app launch, inject where needed. -3. **No SwiftUI dependency** — pure Foundation. Works in watchOS, macOS, widgets, extensions. -4. **Protocol-based sync** — `BLSyncAdapter` lets each product define its own DTOs and endpoints while reusing the queue/timer/conflict plumbing. -5. **Fire-and-forget telemetry** — errors never surface to the user. Matches the TypeScript package behavior. - -## Platforms - -- iOS 17+ -- watchOS 10+ -- macOS 14+ - -## Broadcast & Survey - -New in v1.2: In-app messaging and survey capabilities. - -### Broadcast Client - -```swift -import ByteLystPlatformSDK - -let config = BLPlatformConfig( - productId: "lysnrai", - baseURL: URL(string: "https://api.bytelyst.io/v1")!, - getAuthToken: { await getToken() } -) - -let broadcastClient = BLBroadcastClient(config: config) - -// Start polling for messages -broadcastClient.startPolling(intervalMs: 60000) { messages in - // Handle new messages -} - -// SwiftUI UI components -BLInAppMessageBanner(client: broadcastClient, position: .top) -BLBroadcastModal(client: broadcastClient) -``` - -### Survey Client - -```swift -let surveyClient = BLSurveyClient(config: config) - -// Check for active surveys -let (survey, _) = await surveyClient.getActiveSurvey() - -// Start and complete survey -await surveyClient.startSurvey(surveyId: survey.id) -await surveyClient.submitAnswer(surveyId: survey.id, questionId: "q1", answer: answer) -await surveyClient.completeSurvey(surveyId: survey.id) - -// SwiftUI modal -BLSurveyModal(client: surveyClient) -``` - -See [Broadcast & Survey Guide](BROADCAST_SURVEY_GUIDE.md) for full documentation. - diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift deleted file mode 100644 index 89c4802..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuditLogger.swift +++ /dev/null @@ -1,82 +0,0 @@ -// ── Audit Logger ──────────────────────────────────────────── -// Generic local audit logger that tracks user actions for debugging. -// Stores events in a rotating JSON file (configurable max entries). -// Product apps configure with a product-specific file name. - -import Foundation - -/// Audit event stored locally. -public struct BLAuditEvent: Codable, Sendable { - public let id: String - public let action: String - public let details: String? - public let timestamp: Date - - public init(action: String, details: String? = nil) { - self.id = UUID().uuidString - self.action = action - self.details = details - self.timestamp = Date() - } -} - -/// Generic local audit logger for all ByteLyst iOS apps. -/// Stores events in a rotating JSON file in the Documents directory. -public enum BLAuditLogger { - - private static var maxEvents = 1000 - private static var fileName = "audit_log.json" - - /// Configure the logger with a product-specific file name and max events. - public static func configure(fileName: String = "audit_log.json", maxEvents: Int = 1000) { - self.fileName = fileName - self.maxEvents = maxEvents - } - - private static var fileURL: URL { - let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - return docs.appendingPathComponent(fileName) - } - - /// Log a user action. - public static func log(_ action: String, details: String? = nil) { - let event = BLAuditEvent(action: action, details: details) - - var events = loadEvents() - events.append(event) - - // Rotate: keep only the most recent maxEvents - if events.count > maxEvents { - events = Array(events.suffix(maxEvents)) - } - - saveEvents(events) - } - - /// Get all logged events (newest first). - public static func getEvents(limit: Int = 100) -> [BLAuditEvent] { - let events = loadEvents() - return Array(events.suffix(limit).reversed()) - } - - /// Clear all audit logs. - public static func clear() { - try? FileManager.default.removeItem(at: fileURL) - } - - // MARK: - Persistence - - private static func loadEvents() -> [BLAuditEvent] { - guard let data = try? Data(contentsOf: fileURL) else { return [] } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return (try? decoder.decode([BLAuditEvent].self, from: data)) ?? [] - } - - private static func saveEvents(_ events: [BLAuditEvent]) { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(events) else { return } - try? data.write(to: fileURL, options: .atomic) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift deleted file mode 100644 index 89b0616..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthClient.swift +++ /dev/null @@ -1,665 +0,0 @@ -// ── Auth Client ────────────────────────────────────────────── -// Generic auth client matching @bytelyst/auth-client TypeScript interface. -// Login, register, refresh, forgot/reset/change password, verify email, delete account. -// SmartAuth v2: social login, MFA (TOTP), passkeys, device trust. -// Token storage via BLKeychain. Product apps configure via BLPlatformConfig. - -import Foundation - -// MARK: - Public Types - -public struct BLAuthUser: Codable, Sendable { - public let id: String - public let email: String - public let displayName: String - public let plan: String - public let role: String - - enum CodingKeys: String, CodingKey { - case id, email, displayName, plan, role - } - - public init(id: String, email: String, displayName: String, plan: String = "free", role: String = "user") { - self.id = id - self.email = email - self.displayName = displayName - self.plan = plan - self.role = role - } - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - email = try c.decode(String.self, forKey: .email) - displayName = try c.decode(String.self, forKey: .displayName) - plan = try c.decodeIfPresent(String.self, forKey: .plan) ?? "free" - role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" - } -} - -public enum BLAuthState: Sendable { - case loading - case loggedOut - case loggedIn(BLAuthUser) - case mfaRequired(BLMfaChallenge) - case error(String) -} - -// MARK: - SmartAuth Types - -/// MFA challenge returned when login requires multi-factor verification. -public struct BLMfaChallenge: Codable, Sendable { - public let mfaRequired: Bool - public let mfaChallenge: String - public let methods: [String] - - public init(mfaRequired: Bool, mfaChallenge: String, methods: [String]) { - self.mfaRequired = mfaRequired - self.mfaChallenge = mfaChallenge - self.methods = methods - } -} - -/// TOTP setup response with secret URI and recovery codes. -public struct BLTotpSetup: Codable, Sendable { - public let otpauthUri: String - public let qrCode: String - public let recoveryCodes: [String] -} - -/// MFA status for the current user. -public struct BLMfaStatus: Codable, Sendable { - public let mfaEnabled: Bool - public let methods: [String] - public let recoveryCodesRemaining: Int -} - -/// Linked OAuth provider. -public struct BLAuthProvider: Codable, Sendable { - public let provider: String - public let email: String - public let linkedAt: String - public let lastUsedAt: String? -} - -/// Passkey metadata. -public struct BLPasskey: Codable, Sendable { - public let id: String - public let friendlyName: String - public let deviceType: String - public let lastUsedAt: String? - public let createdAt: String -} - -/// Trusted/remembered device. -public struct BLDevice: Codable, Sendable { - public let fingerprint: String - public let trustLevel: String - public let deviceInfo: DeviceInfo? - public let lastIp: String? - public let lastLocation: String? - public let trustExpiresAt: String? - public let createdAt: String - public let lastSeenAt: String - public let isTrusted: Bool - - public struct DeviceInfo: Codable, Sendable { - public let userAgent: String? - public let platform: String? - public let model: String? - public let os: String? - } - - /// Convenience: display name derived from device info. - public var name: String { - deviceInfo?.model ?? deviceInfo?.platform ?? fingerprint.prefix(8).description - } - - /// Convenience: platform string. - public var platform: String { - deviceInfo?.platform ?? "unknown" - } - - /// Stable identifier for SwiftUI ForEach. - public var id: String { fingerprint } -} - -/// Login event for security log. -public struct BLLoginEvent: Codable, Sendable { - public let id: String - public let eventType: String - public let method: String - public let ip: String - public let geo: BLGeo? - public let riskScore: Int - public let createdAt: String -} - -/// Geo location. -public struct BLGeo: Codable, Sendable { - public let country: String - public let city: String -} - -// MARK: - Phase 5C–5E Types - -/// Decrypted TOTP secret for local code generation in the auth app. -public struct BLTotpSecret: Codable, Sendable { - public let secret: String - public let issuer: String - public let accountName: String - public let digits: Int - public let period: Int - public let algorithm: String -} - -/// Pending push MFA approval. -public struct BLPushApproval: Codable, Sendable { - public let id: String - public let requestProductId: String - public let requestPlatform: String - public let requestIp: String - public let requestGeo: BLGeo? - public let createdAt: String - public let expiresAt: String -} - -/// Push approval response after approve/deny. -public struct BLPushApprovalResponse: Codable, Sendable { - public let id: String - public let status: String - public let respondedAt: String? -} - -/// QR challenge for desktop/TV login. -public struct BLQrChallenge: Codable, Sendable { - public let id: String - public let challengeToken: String - public let expiresAt: String -} - -/// QR challenge poll status. -public struct BLQrStatus: Codable, Sendable { - public let status: String - public let accessToken: String? - public let refreshToken: String? - public let user: BLAuthUser? -} - -// MARK: - Auth Errors - -/// Auth-specific errors for SmartAuth flows. -public enum BLAuthError: LocalizedError { - case mfaRequired(BLMfaChallenge) - - public var errorDescription: String? { - switch self { - case .mfaRequired: return "Multi-factor authentication required" - } - } -} - -// MARK: - Auth Client - -/// Generic auth client for all ByteLyst iOS apps. -/// Handles login, register, token refresh, password operations, and account management. -/// SmartAuth v2: social login, MFA, passkeys, device trust, step-up auth. -/// Stores tokens in Keychain. Notifies via `onAuthStateChanged` callback. -public final class BLAuthClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let keychainService: String - - /// Called whenever auth state changes. Set by the product app's view model / observable. - public var onAuthStateChanged: ((BLAuthState) -> Void)? - - /// Called when tokens are updated (for wiring into sync managers). - public var onTokensUpdated: ((String?) -> Void)? - - private var refreshTimer: Timer? - - private struct TokenResponse: Codable { - let accessToken: String - let refreshToken: String - let user: BLAuthUser - } - - private struct RefreshResponse: Codable { - let accessToken: String - let refreshToken: String - } - - private struct MessageResponse: Codable { - let message: String - } - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - self.keychainService = config.bundleId - } - - // MARK: - Token Access - - public var accessToken: String? { - BLKeychain.read(service: keychainService, key: "access_token") - } - - public var refreshTokenValue: String? { - BLKeychain.read(service: keychainService, key: "refresh_token") - } - - public var isAuthenticated: Bool { - accessToken != nil && !(accessToken?.isEmpty ?? true) - } - - // MARK: - Auth Operations - - /// Login with email and password. - /// If MFA is enabled, throws `BLAuthError.mfaRequired` with the challenge. - public func login(email: String, password: String) async throws -> BLAuthUser { - let body: [String: String] = [ - "email": email, - "password": password, - "productId": config.productId, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/login", method: "POST", body: body) - // Check for MFA challenge response - if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), - challenge.mfaRequired { - onAuthStateChanged?(.mfaRequired(challenge)) - throw BLAuthError.mfaRequired(challenge) - } - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// Register a new account. - public func register(displayName: String, email: String, password: String) async throws -> BLAuthUser { - let body: [String: String] = [ - "displayName": displayName, - "email": email, - "password": password, - "productId": config.productId, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/register", method: "POST", body: body) - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// Fetch current user profile. - public func getMe() async throws -> BLAuthUser { - return try await client.request(path: "/api/auth/me", responseType: BLAuthUser.self) - } - - /// Refresh the access token using the stored refresh token. - @discardableResult - public func refreshAccessToken() async -> Bool { - guard let rt = refreshTokenValue, !rt.isEmpty else { return false } - let body = ["refreshToken": rt] - - do { - let (data, _) = try await client.rawRequest(path: "/api/auth/refresh", method: "POST", body: body) - let result = try JSONDecoder().decode(RefreshResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - return true - } catch { - if let netErr = error as? BLNetworkError, netErr.statusCode == 401 { - logout() - } - return false - } - } - - /// Request password reset email. - public func forgotPassword(email: String) async throws { - let body = ["email": email, "productId": config.productId] - _ = try await client.rawRequest(path: "/api/auth/forgot-password", method: "POST", body: body) - } - - /// Reset password with token. - public func resetPassword(token: String, newPassword: String) async throws { - let body = ["token": token, "newPassword": newPassword] - _ = try await client.rawRequest(path: "/api/auth/reset-password", method: "POST", body: body) - } - - /// Change password (authenticated). - public func changePassword(currentPassword: String, newPassword: String) async throws { - let body = ["currentPassword": currentPassword, "newPassword": newPassword] - _ = try await client.rawRequest(path: "/api/auth/change-password", method: "POST", body: body) - } - - /// Verify email with token. - public func verifyEmail(token: String) async throws { - let body = ["token": token] - _ = try await client.rawRequest(path: "/api/auth/verify-email", method: "POST", body: body) - } - - /// Resend verification email. - public func resendVerification(email: String) async throws { - let body = ["email": email, "productId": config.productId] - _ = try await client.rawRequest(path: "/api/auth/resend-verification", method: "POST", body: body) - } - - /// Delete account (requires password confirmation). - public func deleteAccount(password: String) async throws { - let body = ["password": password] - _ = try await client.rawRequest(path: "/api/auth/account", method: "DELETE", body: body) - logout() - } - - /// Logout — clear tokens and notify. - public func logout() { - stopRefreshTimer() - clearTokens() - onAuthStateChanged?(.loggedOut) - } - - // MARK: - Social Login (SmartAuth v2) - - /// Login with Google id_token. - public func loginWithGoogle(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "google", idToken: idToken) - } - - /// Login with Microsoft id_token. - public func loginWithMicrosoft(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "microsoft", idToken: idToken) - } - - /// Login with Apple id_token. - public func loginWithApple(idToken: String) async throws -> BLAuthUser { - return try await socialLogin(provider: "apple", idToken: idToken) - } - - /// Generic social login — sends id_token to /auth/oauth/{provider}. - private func socialLogin(provider: String, idToken: String) async throws -> BLAuthUser { - let body: [String: String] = ["idToken": idToken, "productId": config.productId] - let (data, _) = try await client.rawRequest( - path: "/api/auth/oauth/\(provider)", - method: "POST", - body: body - ) - // Server may return MFA challenge or tokens - if let challenge = try? JSONDecoder().decode(BLMfaChallenge.self, from: data), - challenge.mfaRequired { - onAuthStateChanged?(.mfaRequired(challenge)) - throw BLAuthError.mfaRequired(challenge) - } - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - // MARK: - MFA (SmartAuth v2) - - /// Verify MFA challenge (TOTP code or recovery code). - public func verifyMfa(challengeToken: String, code: String, method: String = "totp") async throws -> BLAuthUser { - let body: [String: String] = [ - "challengeToken": challengeToken, - "code": code, - "method": method, - ] - let (data, _) = try await client.rawRequest(path: "/api/auth/mfa/verify", method: "POST", body: body) - let result = try JSONDecoder().decode(TokenResponse.self, from: data) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - return result.user - } - - /// Begin TOTP setup — returns otpauth URI, QR code, and recovery codes. - public func setupTotp() async throws -> BLTotpSetup { - return try await client.request(path: "/api/auth/mfa/totp/setup", method: "POST", responseType: BLTotpSetup.self) - } - - /// Verify TOTP setup with a code from the authenticator app. - public func verifyTotpSetup(code: String) async throws { - let body = ["code": code] - _ = try await client.rawRequest(path: "/api/auth/mfa/totp/verify-setup", method: "POST", body: body) - } - - /// Disable MFA (requires step-up token via X-Step-Up-Token header). - public func disableMfa() async throws { - _ = try await client.rawRequest(path: "/api/auth/mfa/totp", method: "DELETE") - } - - /// Get current MFA status. - public func getMfaStatus() async throws -> BLMfaStatus { - return try await client.request(path: "/api/auth/mfa/status", responseType: BLMfaStatus.self) - } - - /// Regenerate recovery codes (requires step-up). - public func regenerateRecoveryCodes() async throws -> [String] { - struct CodesResponse: Codable { let recoveryCodes: [String] } - let result = try await client.request( - path: "/api/auth/mfa/recovery/regenerate", - method: "POST", - responseType: CodesResponse.self - ) - return result.recoveryCodes - } - - // MARK: - Providers (SmartAuth v2) - - /// List linked OAuth providers. - public func getProviders() async throws -> [BLAuthProvider] { - return try await client.request(path: "/api/auth/providers", responseType: [BLAuthProvider].self) - } - - /// Link an OAuth provider to the current account. - public func linkProvider(provider: String, idToken: String) async throws { - let body = ["provider": provider, "idToken": idToken] - _ = try await client.rawRequest(path: "/api/auth/providers/link", method: "POST", body: body) - } - - /// Unlink an OAuth provider. - public func unlinkProvider(provider: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/providers/\(provider)", method: "DELETE") - } - - // MARK: - Passkeys (SmartAuth v2) - - /// Get passkey registration options from server. - public func getPasskeyRegistrationOptions() async throws -> Data { - let (data, _) = try await client.rawRequest( - path: "/api/auth/passkeys/register/options", - method: "POST" - ) - return data - } - - /// Verify passkey registration with attestation response. - public func verifyPasskeyRegistration(attestation: [String: Any], friendlyName: String) async throws { - var payload = attestation - payload["friendlyName"] = friendlyName - let jsonData = try JSONSerialization.data(withJSONObject: payload) - // Wrap raw JSON data in a Codable struct for BLPlatformClient - _ = try await client.rawRequest( - path: "/api/auth/passkeys/register/verify", - method: "POST", - rawBody: jsonData - ) - } - - /// Get passkey authentication options from server. - public func getPasskeyAuthenticationOptions() async throws -> Data { - let (data, _) = try await client.rawRequest( - path: "/api/auth/passkeys/authenticate/options", - method: "POST" - ) - return data - } - - /// Verify passkey authentication with assertion response. - public func verifyPasskeyAuthentication(assertion: [String: Any]) async throws -> BLAuthUser { - let jsonData = try JSONSerialization.data(withJSONObject: assertion) - let (responseData, _) = try await client.rawRequest( - path: "/api/auth/passkeys/authenticate/verify", - method: "POST", - rawBody: jsonData - ) - let result = try JSONDecoder().decode(TokenResponse.self, from: responseData) - saveTokens(access: result.accessToken, refresh: result.refreshToken) - startRefreshTimer() - onAuthStateChanged?(.loggedIn(result.user)) - return result.user - } - - /// List registered passkeys. - public func listPasskeys() async throws -> [BLPasskey] { - return try await client.request(path: "/api/auth/passkeys", responseType: [BLPasskey].self) - } - - /// Delete a passkey (requires step-up). - public func deletePasskey(passkeyId: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/passkeys/\(passkeyId)", method: "DELETE") - } - - // MARK: - Devices (SmartAuth v2) - - /// List devices for current user. - public func listDevices() async throws -> [BLDevice] { - struct DevicesResponse: Codable { let devices: [BLDevice] } - let result = try await client.request(path: "/api/auth/devices", responseType: DevicesResponse.self) - return result.devices - } - - /// Trust the current device (promotes to trusted, skips MFA for 90 days). - public func trustDevice() async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/trust", method: "POST") - } - - /// Revoke trust on a specific device by fingerprint. - public func revokeDevice(fingerprint: String) async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/\(fingerprint)", method: "DELETE") - } - - /// Revoke all device trust. - public func revokeAllDevices() async throws { - _ = try await client.rawRequest(path: "/api/auth/devices/revoke-all", method: "POST") - } - - // MARK: - Step-Up Auth (SmartAuth v2) - - /// Perform step-up authentication. Returns a short-lived step-up token. - public func stepUp(method: String, credential: String) async throws -> String { - let body = ["method": method, "credential": credential] - struct StepUpResponse: Codable { let stepUpToken: String } - let result = try await client.request( - path: "/api/auth/step-up", - method: "POST", - body: body, - responseType: StepUpResponse.self - ) - return result.stepUpToken - } - - // MARK: - Login History (SmartAuth v2) - - /// Get login events for the current user. - public func getLoginHistory(limit: Int = 20) async throws -> [BLLoginEvent] { - struct EventsResponse: Codable { let events: [BLLoginEvent] } - let result = try await client.request( - path: "/api/auth/login-events?limit=\(limit)", - responseType: EventsResponse.self - ) - return result.events - } - - // MARK: - TOTP Secret Retrieval (Phase 5C) - - /// Get the decrypted TOTP secret for local code generation (auth app). - public func getTotpSecret() async throws -> BLTotpSecret { - return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self) - } - - // MARK: - Push Approvals (Phase 5D) - - /// List pending push MFA approvals for the current user. - public func getPendingApprovals() async throws -> [BLPushApproval] { - return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self) - } - - /// Respond to a push MFA approval (approve or deny). - public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse { - let body = ["action": action] - return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self) - } - - // MARK: - QR Auth (Phase 5E) - - /// Confirm a QR login challenge from the auth app. - public func confirmQrLogin(challengeToken: String) async throws { - let body = ["challengeToken": challengeToken] - _ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body) - } - - /// Restore session from stored tokens. Call on app launch. - public func restoreSession() async { - guard isAuthenticated else { - onAuthStateChanged?(.loggedOut) - return - } - - onAuthStateChanged?(.loading) - - do { - let user = try await getMe() - onAuthStateChanged?(.loggedIn(user)) - startRefreshTimer() - } catch { - // Try refresh - let ok = await refreshAccessToken() - if ok { - do { - let user = try await getMe() - onAuthStateChanged?(.loggedIn(user)) - startRefreshTimer() - } catch { - onAuthStateChanged?(.loggedOut) - } - } else { - onAuthStateChanged?(.loggedOut) - } - } - } - - // MARK: - Private - - private func saveTokens(access: String, refresh: String) { - BLKeychain.save(service: keychainService, key: "access_token", value: access) - BLKeychain.save(service: keychainService, key: "refresh_token", value: refresh) - client.authToken = access - onTokensUpdated?(access) - } - - private func clearTokens() { - BLKeychain.delete(service: keychainService, key: "access_token") - BLKeychain.delete(service: keychainService, key: "refresh_token") - client.authToken = nil - onTokensUpdated?(nil) - } - - private func startRefreshTimer() { - stopRefreshTimer() - // Refresh every 12 minutes (access tokens expire in 15 minutes per PRD) - refreshTimer = Timer.scheduledTimer(withTimeInterval: 12 * 60, repeats: true) { [weak self] _ in - guard let self else { return } - Task { await self.refreshAccessToken() } - } - } - - private func stopRefreshTimer() { - refreshTimer?.invalidate() - refreshTimer = nil - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift deleted file mode 100644 index 4a86ecb..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLAuthUI.swift +++ /dev/null @@ -1,740 +0,0 @@ -// ── Auth UI Kit ───────────────────────────────────────────── -// Reusable SwiftUI auth views for all ByteLyst iOS/macOS apps. -// BLLoginView, BLMfaChallengeView, BLPasskeyView, BLStepUpSheet. -// Themed via @Environment injection — always matches host product. - -import SwiftUI -import os - -#if canImport(AuthenticationServices) -import AuthenticationServices -#endif - -private let logger = Logger(subsystem: "com.bytelyst.platform", category: "BLAuthUI") - -// MARK: - Auth UI Configuration - -/// Configuration for BLAuthUI views — passed via Environment. -public struct BLAuthUIConfig { - public let productName: String - public let accentColor: Color - public let backgroundColor: Color - public let textColor: Color - public let secondaryTextColor: Color - public let cardColor: Color - public let enabledProviders: [BLAuthUIProvider] - - public init( - productName: String = "ByteLyst", - accentColor: Color = .blue, - backgroundColor: Color = Color(.systemBackground), - textColor: Color = .primary, - secondaryTextColor: Color = .secondary, - cardColor: Color = Color(.secondarySystemBackground), - enabledProviders: [BLAuthUIProvider] = [.google, .apple] - ) { - self.productName = productName - self.accentColor = accentColor - self.backgroundColor = backgroundColor - self.textColor = textColor - self.secondaryTextColor = secondaryTextColor - self.cardColor = cardColor - self.enabledProviders = enabledProviders - } -} - -/// OAuth providers supported by BLAuthUI. -public enum BLAuthUIProvider: String, CaseIterable, Sendable { - case google - case microsoft - case apple -} - -// MARK: - Environment Key - -private struct BLAuthUIConfigKey: EnvironmentKey { - static let defaultValue = BLAuthUIConfig() -} - -extension EnvironmentValues { - public var blAuthUIConfig: BLAuthUIConfig { - get { self[BLAuthUIConfigKey.self] } - set { self[BLAuthUIConfigKey.self] = newValue } - } -} - -// MARK: - BLLoginView - -/// Full login view with email/password + social buttons + passkey option. -/// Host product injects theme via `.environment(\.blAuthUIConfig, config)`. -public struct BLLoginView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var email = "" - @State private var password = "" - @State private var isLoading = false - @State private var errorMessage: String? - - /// Called with email + password when user taps Sign In. - public var onLogin: (String, String) async throws -> Void - /// Called with provider name when user taps a social button. - public var onSocialLogin: (BLAuthUIProvider) -> Void - /// Called when user taps "Use Passkey". - public var onPasskeyLogin: (() -> Void)? - /// Called when user taps "Forgot Password?". - public var onForgotPassword: (() -> Void)? - /// Called when user taps "Create Account". - public var onCreateAccount: (() -> Void)? - - public init( - onLogin: @escaping (String, String) async throws -> Void, - onSocialLogin: @escaping (BLAuthUIProvider) -> Void, - onPasskeyLogin: (() -> Void)? = nil, - onForgotPassword: (() -> Void)? = nil, - onCreateAccount: (() -> Void)? = nil - ) { - self.onLogin = onLogin - self.onSocialLogin = onSocialLogin - self.onPasskeyLogin = onPasskeyLogin - self.onForgotPassword = onForgotPassword - self.onCreateAccount = onCreateAccount - } - - public var body: some View { - ScrollView { - VStack(spacing: 24) { - // Header - VStack(spacing: 8) { - Text("Sign in to \(config.productName)") - .font(.title2.bold()) - .foregroundColor(config.textColor) - Text("Welcome back") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - } - .padding(.top, 40) - - // Social Buttons - if !config.enabledProviders.isEmpty { - VStack(spacing: 12) { - ForEach(config.enabledProviders, id: \.self) { provider in - socialButton(for: provider) - } - } - - dividerRow - } - - // Email / Password - VStack(spacing: 16) { - TextField("Email", text: $email) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - } - - // Error - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - .multilineTextAlignment(.center) - } - - // Sign In Button - Button { - Task { await performLogin() } - } label: { - HStack { - if isLoading { - ProgressView() - .tint(.white) - } - Text("Sign In") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(email.isEmpty || password.isEmpty || isLoading) - - // Passkey - if let onPasskeyLogin { - Button { - logger.debug("Passkey login tapped") - onPasskeyLogin() - } label: { - HStack { - Image(systemName: "person.badge.key.fill") - Text("Sign in with Passkey") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - } - } - - // Forgot Password / Create Account - VStack(spacing: 12) { - if let onForgotPassword { - Button("Forgot Password?") { - onForgotPassword() - } - .font(.subheadline) - .foregroundColor(config.accentColor) - } - - if let onCreateAccount { - HStack { - Text("Don't have an account?") - .foregroundColor(config.secondaryTextColor) - Button("Create Account") { - onCreateAccount() - } - .foregroundColor(config.accentColor) - } - .font(.subheadline) - } - } - } - .padding(.horizontal, 24) - } - .background(config.backgroundColor.ignoresSafeArea()) - } - - private func performLogin() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Attempting email/password login for \(email)") - try await onLogin(email, password) - } catch let error as BLAuthError { - // MFA required is not an error for the user — handled upstream - logger.info("MFA required for \(email)") - _ = error // Suppress unused warning - } catch { - logger.error("Login failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } - - @ViewBuilder - private func socialButton(for provider: BLAuthUIProvider) -> some View { - Button { - logger.debug("Social login: \(provider.rawValue)") - onSocialLogin(provider) - } label: { - HStack { - providerIcon(for: provider) - Text("Continue with \(providerDisplayName(provider))") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - } - - @ViewBuilder - private func providerIcon(for provider: BLAuthUIProvider) -> some View { - switch provider { - case .google: - Image(systemName: "globe") - case .microsoft: - Image(systemName: "building.2") - case .apple: - Image(systemName: "applelogo") - } - } - - private func providerDisplayName(_ provider: BLAuthUIProvider) -> String { - switch provider { - case .google: return "Google" - case .microsoft: return "Microsoft" - case .apple: return "Apple" - } - } - - private var dividerRow: some View { - HStack { - Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) - Text("or") - .font(.caption) - .foregroundColor(config.secondaryTextColor) - Rectangle().fill(Color.gray.opacity(0.3)).frame(height: 1) - } - } -} - -// MARK: - BLMfaChallengeView - -/// 6-digit TOTP code entry with countdown and recovery code fallback. -public struct BLMfaChallengeView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var code = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showRecovery = false - - public let challenge: BLMfaChallenge - public var onVerify: (String, String, String) async throws -> Void - public var onCancel: (() -> Void)? - - public init( - challenge: BLMfaChallenge, - onVerify: @escaping (String, String, String) async throws -> Void, - onCancel: (() -> Void)? = nil - ) { - self.challenge = challenge - self.onVerify = onVerify - self.onCancel = onCancel - } - - public var body: some View { - VStack(spacing: 24) { - // Header - VStack(spacing: 8) { - Image(systemName: "lock.shield.fill") - .font(.system(size: 48)) - .foregroundColor(config.accentColor) - - Text("Two-Factor Authentication") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text(showRecovery - ? "Enter a recovery code" - : "Enter the 6-digit code from your authenticator app") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - // Code Input - TextField(showRecovery ? "Recovery Code" : "000000", text: $code) - .textFieldStyle(.roundedBorder) - .keyboardType(showRecovery ? .default : .numberPad) - .multilineTextAlignment(.center) - .font(.title2.monospaced()) - .frame(maxWidth: 200) - - // Error - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - // Verify Button - Button { - Task { await verify() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Text("Verify") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(code.isEmpty || isLoading) - - // Toggle recovery / cancel - VStack(spacing: 12) { - Button(showRecovery ? "Use authenticator code" : "Use recovery code") { - showRecovery.toggle() - code = "" - errorMessage = nil - } - .font(.subheadline) - .foregroundColor(config.accentColor) - - if let onCancel { - Button("Cancel") { onCancel() } - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - } - } - } - .padding(24) - .background(config.backgroundColor) - } - - private func verify() async { - isLoading = true - errorMessage = nil - let method = showRecovery ? "recovery" : "totp" - do { - logger.debug("Verifying MFA: method=\(method)") - try await onVerify(challenge.mfaChallenge, code, method) - } catch { - logger.error("MFA verify failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - code = "" - } - isLoading = false - } -} - -// MARK: - BLPasskeyView - -/// Passkey prompt with biometric hint text. -/// Triggers ASAuthorizationController for platform passkey authentication. -public struct BLPasskeyView: View { - @Environment(\.blAuthUIConfig) private var config - - @State private var isLoading = false - @State private var errorMessage: String? - - public var onAuthenticate: () async throws -> Void - public var onCancel: (() -> Void)? - - public init( - onAuthenticate: @escaping () async throws -> Void, - onCancel: (() -> Void)? = nil - ) { - self.onAuthenticate = onAuthenticate - self.onCancel = onCancel - } - - public var body: some View { - VStack(spacing: 24) { - VStack(spacing: 12) { - Image(systemName: "person.badge.key.fill") - .font(.system(size: 48)) - .foregroundColor(config.accentColor) - - Text("Sign in with Passkey") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text("Use Face ID, Touch ID, or your security key to sign in") - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - Button { - Task { await authenticate() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Image(systemName: "faceid") - Text("Continue") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(isLoading) - - if let onCancel { - Button("Use another method") { onCancel() } - .font(.subheadline) - .foregroundColor(config.accentColor) - } - } - .padding(24) - .background(config.backgroundColor) - } - - private func authenticate() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Starting passkey authentication") - try await onAuthenticate() - } catch { - logger.error("Passkey auth failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } -} - -// MARK: - BLStepUpSheet - -/// Re-authentication sheet for sensitive operations. -/// Supports password re-entry or biometric confirmation. -public struct BLStepUpSheet: View { - @Environment(\.blAuthUIConfig) private var config - @Environment(\.dismiss) private var dismiss - - @State private var password = "" - @State private var isLoading = false - @State private var errorMessage: String? - - public let reason: String - public var onStepUp: (String, String) async throws -> String - public var onComplete: (String) -> Void - - public init( - reason: String = "This action requires re-authentication", - onStepUp: @escaping (String, String) async throws -> String, - onComplete: @escaping (String) -> Void - ) { - self.reason = reason - self.onStepUp = onStepUp - self.onComplete = onComplete - } - - public var body: some View { - NavigationStack { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "lock.fill") - .font(.system(size: 36)) - .foregroundColor(config.accentColor) - - Text("Confirm Your Identity") - .font(.title3.bold()) - .foregroundColor(config.textColor) - - Text(reason) - .font(.subheadline) - .foregroundColor(config.secondaryTextColor) - .multilineTextAlignment(.center) - } - - SecureField("Password", text: $password) - .textFieldStyle(.roundedBorder) - .textContentType(.password) - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundColor(.red) - } - - #if canImport(LocalAuthentication) - Button { - Task { await biometricStepUp() } - } label: { - HStack { - Image(systemName: "faceid") - Text("Use Biometrics") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.cardColor) - .foregroundColor(config.textColor) - .cornerRadius(10) - } - #endif - - Button { - Task { await passwordStepUp() } - } label: { - HStack { - if isLoading { ProgressView().tint(.white) } - Text("Confirm") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(config.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(password.isEmpty || isLoading) - - Spacer() - } - .padding(24) - .background(config.backgroundColor) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } - - private func passwordStepUp() async { - isLoading = true - errorMessage = nil - do { - logger.debug("Step-up: password method") - let token = try await onStepUp("password", password) - onComplete(token) - dismiss() - } catch { - logger.error("Step-up failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } - - #if canImport(LocalAuthentication) - private func biometricStepUp() async { - let success = await BLBiometricAuth.authenticate(reason: "Confirm your identity") - if success { - isLoading = true - errorMessage = nil - do { - logger.debug("Step-up: biometric method") - let token = try await onStepUp("biometric", "biometric_verified") - onComplete(token) - dismiss() - } catch { - logger.error("Biometric step-up failed: \(error.localizedDescription)") - errorMessage = error.localizedDescription - } - isLoading = false - } else { - errorMessage = "Biometric authentication failed" - } - } - #endif -} - -// ── BLDeviceListView ──────────────────────────────────────── - -/// Device management view — list trusted/remembered devices, revoke trust. -/// Mirrors the Kotlin `BLDeviceListScreen` for platform parity. -/// -/// - Parameters: -/// - devices: List of devices from `BLAuthClient.listDevices()`. -/// - onRevokeDevice: Called with device ID when user revokes a device. -/// - onRevokeAll: Called when user revokes all devices. `nil` hides the button. -/// - isLoading: Whether data is loading. -public struct BLDeviceListView: View { - public let devices: [BLDevice] - public let onRevokeDevice: (String) -> Void - public var onRevokeAll: (() -> Void)? - public var isLoading: Bool = false - - public init( - devices: [BLDevice], - onRevokeDevice: @escaping (String) -> Void, - onRevokeAll: (() -> Void)? = nil, - isLoading: Bool = false - ) { - self.devices = devices - self.onRevokeDevice = onRevokeDevice - self.onRevokeAll = onRevokeAll - self.isLoading = isLoading - } - - public var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Your Devices") - .font(.title2.bold()) - Spacer() - if let onRevokeAll, !devices.isEmpty { - Button(role: .destructive, action: onRevokeAll) { - Text("Revoke All") - } - } - } - - if isLoading { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 32) - } else if devices.isEmpty { - Text("No devices found") - .foregroundStyle(.secondary) - .padding(.vertical, 16) - } else { - ForEach(devices, id: \.id) { device in - DeviceCardView(device: device) { - onRevokeDevice(device.id) - } - } - } - } - .padding() - } - } -} - -/// Individual device card within BLDeviceListView. -private struct DeviceCardView: View { - let device: BLDevice - let onRevoke: () -> Void - - private var platformIcon: String { - switch device.platform { - case "ios": return "iphone" - case "android": return "apps.iphone" - case "macos": return "laptopcomputer" - case "windows", "linux": return "desktopcomputer" - default: return "display" - } - } - - private var trustColor: Color { - switch device.trustLevel { - case "trusted": return .blue - case "remembered": return .green - default: return .secondary - } - } - - var body: some View { - HStack(spacing: 12) { - Image(systemName: platformIcon) - .font(.title2) - .foregroundStyle(trustColor) - .frame(width: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.body) - Text("\(device.trustLevel) · \(device.platform)") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if device.trustLevel == "trusted" || device.trustLevel == "remembered" { - Button(role: .destructive, action: onRevoke) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - } - .buttonStyle(.plain) - } - } - .padding() - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift deleted file mode 100644 index 9435ec6..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBiometricAuth.swift +++ /dev/null @@ -1,77 +0,0 @@ -// ── Biometric Authentication ──────────────────────────────── -// Generic Face ID / Touch ID wrapper using LocalAuthentication. -// Product apps pass a custom reason string for the biometric prompt. -// Not available on watchOS — guarded with #if canImport. - -import Foundation - -#if canImport(LocalAuthentication) -import LocalAuthentication - -/// Generic biometric authentication for all ByteLyst iOS/macOS apps. -/// Not available on watchOS (LocalAuthentication is iOS/macOS only). -public enum BLBiometricAuth { - - public enum BiometricType { - case faceID, touchID, none - } - - /// Check what biometric type is available on this device. - public static var availableType: BiometricType { - let context = LAContext() - var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - return .none - } - switch context.biometryType { - case .faceID: return .faceID - case .touchID: return .touchID - default: return .none - } - } - - /// Whether biometric auth is available on this device. - public static var isAvailable: Bool { - availableType != .none - } - - /// Whether the user has enabled biometric lock in settings. - /// Uses a configurable UserDefaults key. - public static func isEnabled(key: String = "biometric_lock_enabled") -> Bool { - UserDefaults.standard.bool(forKey: key) - } - - /// Set biometric lock enabled state. - public static func setEnabled(_ enabled: Bool, key: String = "biometric_lock_enabled") { - UserDefaults.standard.set(enabled, forKey: key) - } - - /// Authenticate with biometrics only. Returns true on success. - public static func authenticate(reason: String = "Unlock app") async -> Bool { - let context = LAContext() - context.localizedCancelTitle = "Use Password" - - do { - return try await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: reason - ) - } catch { - return false - } - } - - /// Authenticate with biometrics or device passcode fallback. - public static func authenticateWithPasscode(reason: String = "Unlock app") async -> Bool { - let context = LAContext() - do { - return try await context.evaluatePolicy( - .deviceOwnerAuthentication, - localizedReason: reason - ) - } catch { - return false - } - } -} -#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift deleted file mode 100644 index a211d32..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBlobClient.swift +++ /dev/null @@ -1,86 +0,0 @@ -// ── Blob Storage Client ───────────────────────────────────── -// Generic Azure Blob Storage client via platform-service SAS tokens. -// Upload files to Azure Blob using SAS URL from POST /api/blob/sas. -// Product apps configure with BLPlatformConfig. - -import Foundation - -/// Response from platform-service SAS token endpoint. -public struct BLSASResponse: Codable, Sendable { - public let sasUrl: String - public let blobUrl: String - public let container: String - public let blobName: String -} - -/// Generic blob storage client for all ByteLyst iOS apps. -/// Handles SAS token acquisition + direct Azure Blob upload. -public final class BLBlobClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - } - - // MARK: - Upload - - /// Upload data to Azure Blob Storage. - /// 1. Acquires SAS token from platform-service - /// 2. Uploads directly to Azure Blob using the SAS URL - /// Returns the permanent blob URL on success. - public func upload( - data: Data, - container: String, - fileName: String, - contentType: String - ) async throws -> String { - // Step 1: Get SAS token - let sas = try await getSASToken(container: container, blobName: fileName, permissions: "w") - - // Step 2: Upload to blob storage using SAS URL - guard let url = URL(string: sas.sasUrl) else { - throw BLNetworkError.invalidURL(sas.sasUrl) - } - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") - request.httpBody = data - request.timeoutInterval = 120 - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse, - (200...299).contains(http.statusCode) else { - throw BLNetworkError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "Blob upload failed") - } - - return sas.blobUrl - } - - /// Convenience: upload audio data. - public func uploadAudio(data: Data, fileName: String) async throws -> String { - try await upload(data: data, container: "audio", fileName: fileName, contentType: "audio/wav") - } - - /// Convenience: upload an attachment (image, document, etc.). - public func uploadAttachment(data: Data, fileName: String, contentType: String) async throws -> String { - try await upload(data: data, container: "attachments", fileName: fileName, contentType: contentType) - } - - // MARK: - SAS Token - - /// Get a SAS token from platform-service. - public func getSASToken(container: String, blobName: String, permissions: String = "r") async throws -> BLSASResponse { - let body = [ - "container": container, - "blobName": blobName, - "permissions": permissions, - ] - return try await client.request(path: "/api/blob/sas", method: "POST", body: body, responseType: BLSASResponse.self) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift deleted file mode 100644 index 888e9d7..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLBroadcastClient.swift +++ /dev/null @@ -1,153 +0,0 @@ -// ── Broadcast Client ───────────────────────────────────── -// In-app broadcast message client for iOS/watchOS/macOS. -// Part of ByteLystPlatformSDK. - -import Foundation - -/// In-app message priority levels. -public enum BLBroadcastPriority: String, Codable, Sendable { - case low = "low" - case normal = "normal" - case high = "high" - case urgent = "urgent" -} - -/// In-app message display styles. -public enum BLBroadcastStyle: String, Codable, Sendable { - case banner = "banner" - case modal = "modal" - case toast = "toast" - case fullscreen = "fullscreen" -} - -/// In-app message status. -public enum BLBroadcastStatus: String, Codable, Sendable { - case unread = "unread" - case read = "read" - case dismissed = "dismissed" -} - -/// Represents an in-app broadcast message. -public struct BLInAppMessage: Codable, Sendable, Identifiable { - public let id: String - public let userId: String - public let productId: String - public let broadcastId: String - public let title: String - public let body: String - public let bodyMarkdown: String? - public let ctaText: String? - public let ctaUrl: String? - public let priority: BLBroadcastPriority - public let style: BLBroadcastStyle - public let dismissible: Bool - public let expiresAt: String? - public let status: BLBroadcastStatus - public let createdAt: String - public let updatedAt: String -} - -/// Broadcast client for fetching and managing in-app messages. -@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) -public class BLBroadcastClient: ObservableObject { - private let platformClient: BLPlatformClient - private var pollTask: Task? - - public init(platformClient: BLPlatformClient) { - self.platformClient = platformClient - } - - /// List active in-app messages for the current user. - public func listMessages() async throws -> [BLInAppMessage] { - let request = try platformClient.buildRequest(path: "/broadcasts") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") - } - - let result = try JSONDecoder().decode(MessagesResponse.self, from: data) - return result.messages - } - - /// Mark a message as read. - public func markRead(messageId: String) async throws { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/read", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to mark message as read") - } - } - - /// Mark a message as dismissed. - public func markDismissed(messageId: String) async throws { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/dismiss", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to dismiss message") - } - } - - /// Track a CTA click and get the redirect URL. - public func trackClick(messageId: String) async throws -> String? { - let request = try platformClient.buildRequest( - path: "/broadcasts/\(messageId)/click", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to track click") - } - - let result = try JSONDecoder().decode(ClickResponse.self, from: data) - return result.redirectUrl - } - - /// Start polling for new messages. - public func startPolling(interval: TimeInterval = 60, onUpdate: @escaping ([BLInAppMessage]) -> Void) { - stopPolling() - - pollTask = Task { - while !Task.isCancelled { - do { - let messages = try await listMessages() - onUpdate(messages) - } catch { - // Silently ignore polling errors - } - - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - /// Stop polling for messages. - public func stopPolling() { - pollTask?.cancel() - pollTask = nil - } -} - -// MARK: - Response Types - -private struct MessagesResponse: Codable { - let messages: [BLInAppMessage] -} - -private struct ClickResponse: Codable { - let success: Bool - let redirectUrl: String? -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift deleted file mode 100644 index 4151fb0..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLCrashReporter.swift +++ /dev/null @@ -1,135 +0,0 @@ -// ── Crash Reporter ────────────────────────────────────────── -// Generic MetricKit-based crash and performance reporting. -// Stores crash diagnostics locally for debugging and feedback forms. -// Product apps configure with a product-specific persistence key. -// Not available on watchOS — MetricKit is iOS/macOS only. - -import Foundation - -/// Crash report model stored locally. -/// Available on all platforms (data-only struct). -public struct BLCrashReport: Codable, Identifiable, Sendable { - public let id: String - public let date: Date - public let exceptionType: String? - public let signal: String? - public let terminationReason: String? - public let callStackData: Data? - - public init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { - self.id = UUID().uuidString - self.date = date - self.exceptionType = exceptionType - self.signal = signal - self.terminationReason = terminationReason - self.callStackData = callStackTree - } -} - -#if canImport(MetricKit) -import MetricKit - -/// Generic MetricKit crash reporter for all ByteLyst iOS/macOS apps. -/// Subscribes to MetricKit, stores crash reports in UserDefaults. -/// Not available on watchOS (MetricKit is iOS 13+ / macOS 12+ only). -@MainActor -public final class BLCrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { - - private let persistenceKey: String - private let maxReports: Int - - @Published public var lastCrashReport: Date? - @Published public var diagnosticCount: Int = 0 - - public init(productId: String, maxReports: Int = 50) { - self.persistenceKey = "\(productId)-crash-reports" - self.maxReports = maxReports - super.init() - MXMetricManager.shared.add(self) - loadStats() - } - - deinit { - MXMetricManager.shared.remove(self) - } - - // MARK: - MXMetricManagerSubscriber - - nonisolated public func didReceive(_ payloads: [MXMetricPayload]) { - // MetricKit delivers daily aggregated metrics — no action needed by default - } - - nonisolated public func didReceive(_ payloads: [MXDiagnosticPayload]) { - Task { @MainActor [weak self] in - guard let self else { return } - for payload in payloads { - self.processDiagnosticPayload(payload) - } - self.diagnosticCount += payloads.count - self.lastCrashReport = Date() - } - } - - // MARK: - Public API - - /// Get all stored crash reports. - public func loadCrashReports() -> [BLCrashReport] { - guard let data = UserDefaults.standard.data(forKey: persistenceKey), - let reports = try? JSONDecoder().decode([BLCrashReport].self, from: data) else { - return [] - } - return reports - } - - /// Clear all stored crash reports. - public func clearReports() { - UserDefaults.standard.removeObject(forKey: persistenceKey) - diagnosticCount = 0 - } - - // MARK: - Private - - private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { - if let crashDiagnostics = payload.crashDiagnostics { - for crash in crashDiagnostics { - let report = BLCrashReport( - date: Date(), - exceptionType: crash.exceptionType?.description, - signal: crash.signal?.description, - terminationReason: crash.terminationReason?.description, - callStackTree: crash.callStackTree.jsonRepresentation() - ) - storeCrashReport(report) - } - } - - if let hangDiagnostics = payload.hangDiagnostics { - for hang in hangDiagnostics { - let report = BLCrashReport( - date: Date(), - exceptionType: nil, - signal: nil, - terminationReason: "Hang: \(hang.hangDuration.description)", - callStackTree: hang.callStackTree.jsonRepresentation() - ) - storeCrashReport(report) - } - } - } - - private func storeCrashReport(_ report: BLCrashReport) { - var reports = loadCrashReports() - reports.append(report) - if reports.count > maxReports { - reports = Array(reports.suffix(maxReports)) - } - if let data = try? JSONEncoder().encode(reports) { - UserDefaults.standard.set(data, forKey: persistenceKey) - } - } - - private func loadStats() { - diagnosticCount = loadCrashReports().count - } -} -#endif diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift deleted file mode 100644 index 96468bf..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLDeepLinkRouter.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Foundation -import os - -/** - * Deep Link Router — Swift - * Handles routing from push notification deep links to app screens - */ - -public struct BLDeepLinkRoute { - public let screen: String - public let params: [String: String] - - public init(screen: String, params: [String: String] = [:]) { - self.screen = screen - self.params = params - } -} - -public typealias BLDeepLinkHandler = (BLDeepLinkRoute) -> Void - -/** - * Deep Link Router class - */ -@available(iOS 15.0, *) -public class BLDeepLinkRouter { - private var handlers: [String: BLDeepLinkHandler] = [:] - private var fallbackHandler: BLDeepLinkHandler? - - public init() {} - - /** - * Register a handler for a specific screen - */ - public func register(screen: String, handler: @escaping BLDeepLinkHandler) { - handlers[screen] = handler - } - - /** - * Set a fallback handler for unregistered screens - */ - public func setFallback(handler: @escaping BLDeepLinkHandler) { - fallbackHandler = handler - } - - /** - * Parse a deep link URL and extract route - */ - public func parseDeepLink(_ urlString: String) -> BLDeepLinkRoute? { - guard let url = URL(string: urlString) else { - return nil - } - - // Handle app-specific URLs: myapp://screen/params - if url.scheme != "http" && url.scheme != "https" { - let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - let screen = pathComponents.first ?? "home" - - var params: [String: String] = [:] - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - for item in queryItems { - if let value = item.value { - params[item.name] = value - } - } - } - - return BLDeepLinkRoute(screen: screen, params: params) - } - - // Handle web URLs with deep link params - if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let dlParam = components.queryItems?.first(where: { $0.name == "dl" })?.value { - return parseDeepLink(dlParam) - } - - // Handle path-based routing: /screen/params - let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - if !pathComponents.isEmpty { - let screen = pathComponents[0] - - var params: [String: String] = [:] - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { - for item in queryItems { - if let value = item.value { - params[item.name] = value - } - } - } - - return BLDeepLinkRoute(screen: screen, params: params) - } - - return nil - } - - /** - * Handle a deep link route - */ - @discardableResult - public func handle(_ route: BLDeepLinkRoute) -> Bool { - if let handler = handlers[route.screen] { - handler(route) - return true - } - - if let fallback = fallbackHandler { - fallback(route) - return true - } - - Logger.deepLink.warning("No handler for screen: \(route.screen)") - return false - } - - /** - * Process a deep link URL end-to-end - */ - @discardableResult - public func process(_ urlString: String) -> Bool { - guard let route = parseDeepLink(urlString) else { - Logger.deepLink.warning("Failed to parse deep link: \(urlString)") - return false - } - return handle(route) - } -} - -/** - * Create a broadcast deep link URL - */ -public func createBroadcastDeepLink( - baseURL: String, - screen: String, - params: [String: String] = [:], - broadcastId: String? = nil -) -> String? { - guard var components = URLComponents(string: baseURL) else { - return nil - } - - components.path = "/\(screen)" - - var queryItems: [URLQueryItem] = params.map { URLQueryItem(name: $0.key, value: $0.value) } - - if let broadcastId = broadcastId { - queryItems.append(URLQueryItem(name: "broadcastId", value: broadcastId)) - } - - if !queryItems.isEmpty { - components.queryItems = queryItems - } - - return components.string -} - -/** - * Common deep link screens - */ -public struct BLDeepLinkScreens { - // Broadcasts - public static let broadcast = "broadcast" - public static let announcements = "announcements" - - // Surveys - public static let survey = "survey" - public static let surveyList = "surveys" - - // Product-specific - public static let settings = "settings" - public static let profile = "profile" - public static let upgrade = "upgrade" - public static let support = "support" - - // Fallback - public static let home = "home" -} - -// Logger extension -@available(iOS 15.0, *) -extension Logger { - static let deepLink = Logger(subsystem: "com.bytelyst.platform", category: "DeepLink") -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift deleted file mode 100644 index 7c89565..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeatureFlagClient.swift +++ /dev/null @@ -1,86 +0,0 @@ -// ── Feature Flag Client ────────────────────────────────────── -// Generic feature flag polling from platform-service /api/flags/poll. -// Flags cached in memory, re-polled at configurable interval. -// Matches the platform-service flags module API. - -import Foundation - -/// Generic feature flag client for all ByteLyst iOS apps. -/// Polls platform-service and caches flag values in memory. -public final class BLFeatureFlagClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let pollIntervalSec: TimeInterval - - private var flags: [String: Bool] = [:] - private let flagsLock = NSLock() - private var pollTimer: Timer? - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - pollIntervalSec: TimeInterval = 5 * 60 - ) { - self.config = config - self.client = client - self.pollIntervalSec = pollIntervalSec - } - - // MARK: - Lifecycle - - /// Start polling for feature flags. - public func start(userId: String? = nil) { - Task { await fetchFlags(userId: userId) } - pollTimer?.invalidate() - pollTimer = Timer.scheduledTimer(withTimeInterval: pollIntervalSec, repeats: true) { [weak self] _ in - guard let self else { return } - Task { await self.fetchFlags(userId: userId) } - } - } - - /// Stop polling. - public func stop() { - pollTimer?.invalidate() - pollTimer = nil - } - - // MARK: - Query - - /// Check if a feature flag is enabled. - public func isEnabled(_ key: String) -> Bool { - flagsLock.lock() - defer { flagsLock.unlock() } - return flags[key] == true - } - - /// Get all current flag values. - public func allFlags() -> [String: Bool] { - flagsLock.lock() - defer { flagsLock.unlock() } - return flags - } - - // MARK: - Fetch - - private struct FlagsResponse: Codable { - let flags: [String: Bool] - } - - private func fetchFlags(userId: String? = nil) async { - var path = "/api/flags/poll?platform=\(config.platform)" - if let userId { path += "&userId=\(userId)" } - - do { - let result = try await client.request( - path: path, - responseType: FlagsResponse.self - ) - flagsLock.lock() - flags = result.flags - flagsLock.unlock() - } catch { - // Keep existing flags on error — silent failure - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift deleted file mode 100644 index efd6e16..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFeedbackClient.swift +++ /dev/null @@ -1,269 +0,0 @@ -// ── Feedback Client ───────────────────────────────────── -// Submit user feedback with optional screenshot attachments. -// Part of ByteLystPlatformSDK for all iOS/watchOS/macOS apps. -// -// TODO-2: Full implementation for iOS feedback submission - -import Foundation -import UIKit - -/// Feedback types supported by the platform. -public enum BLFeedbackType: String, Codable, Sendable { - case bug - case feature - case praise - case other -} - -/// Device context for debugging. -public struct BLDeviceContext: Codable, Sendable { - public let osVersion: String - public let appVersion: String - public let deviceModel: String - public let screenResolution: String - public let locale: String - - public init( - osVersion: String, - appVersion: String, - deviceModel: String, - screenResolution: String, - locale: String - ) { - self.osVersion = osVersion - self.appVersion = appVersion - self.deviceModel = deviceModel - self.screenResolution = screenResolution - self.locale = locale - } - - /// Auto-detect current device context. - public static func current() -> BLDeviceContext { - let device = UIDevice.current - let screen = UIScreen.main - let bounds = screen.bounds - let scale = screen.scale - - return BLDeviceContext( - osVersion: device.systemVersion, - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", - deviceModel: device.model, - screenResolution: "\(Int(bounds.width * scale))x\(Int(bounds.height * scale))", - locale: Locale.current.identifier - ) - } -} - -/// Screenshot content types. -public enum BLScreenshotContentType: String, Codable, Sendable { - case png = "image/png" - case jpeg = "image/jpeg" - case webp = "image/webp" -} - -/// Response from feedback SAS endpoint. -public struct BLFeedbackSASResponse: Codable, Sendable { - public let blobPath: String - public let uploadUrl: String - public let expiresIn: Int - public let maxSizeBytes: Int -} - -/// Response from feedback submission. -public struct BLFeedbackResponse: Codable, Sendable { - public let id: String - public let productId: String - public let userId: String - public let type: String - public let title: String - public let status: String - public let createdAt: String - public let screenshotBlobPath: String? -} - -/// Parameters for submitting feedback. -public struct BLFeedbackParams: Sendable { - public let type: BLFeedbackType - public let title: String - public let body: String? - public let screen: String? - public let rating: Int? - public let screenshot: (data: Data, contentType: BLScreenshotContentType)? - public let deviceContext: BLDeviceContext? - - public init( - type: BLFeedbackType, - title: String, - body: String? = nil, - screen: String? = nil, - rating: Int? = nil, - screenshot: (data: Data, contentType: BLScreenshotContentType)? = nil, - deviceContext: BLDeviceContext? = nil - ) { - self.type = type - self.title = title - self.body = body - self.screen = screen - self.rating = rating - self.screenshot = screenshot - self.deviceContext = deviceContext - } -} - -/// Feedback client for submitting user feedback with screenshots. -/// TODO-2: Full implementation -public final class BLFeedbackClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let blobClient: BLBlobClient - - public init(config: BLPlatformConfig, client: BLPlatformClient, blobClient: BLBlobClient) { - self.config = config - self.client = client - self.blobClient = blobClient - } - - // MARK: - Submit Feedback - - /// Submit feedback with optional screenshot. - /// - /// Flow: - /// 1. If screenshot provided, upload to blob storage - /// 2. Submit feedback with screenshot metadata - /// - /// TODO-2: Full implementation - public func submitFeedback(_ params: BLFeedbackParams) async throws -> BLFeedbackResponse { - var screenshotMeta: (blobPath: String, contentType: String, sizeBytes: Int)? - - // Step 1: Handle screenshot upload if provided - if let screenshot = params.screenshot { - // Get SAS URL for upload - let sas = try await generateSASURL(contentType: screenshot.contentType) - - // Upload screenshot - try await uploadScreenshot(data: screenshot.data, sasURL: sas.uploadUrl, contentType: screenshot.contentType.rawValue) - - screenshotMeta = ( - blobPath: sas.blobPath, - contentType: screenshot.contentType.rawValue, - sizeBytes: screenshot.data.count - ) - } - - // Step 2: Submit feedback - var body: [String: Any] = [ - "type": params.type.rawValue, - "title": params.title, - ] - - if let bodyText = params.body { body["body"] = bodyText } - if let screen = params.screen { body["screen"] = screen } - if let rating = params.rating { body["rating"] = rating } - if let meta = screenshotMeta { - body["screenshotBlobPath"] = meta.blobPath - body["screenshotContentType"] = meta.contentType - body["screenshotSizeBytes"] = meta.sizeBytes - } - if let context = params.deviceContext { - body["deviceContext"] = [ - "osVersion": context.osVersion, - "appVersion": context.appVersion, - "deviceModel": context.deviceModel, - "screenResolution": context.screenResolution, - "locale": context.locale, - ] - } - - throw BLFeedbackError.notImplemented( - "submitFeedback body encoding and API call not yet implemented. " + - "Use client.request(path: \"/api/feedback\", method: \"POST\", body: body)" - ) - } - - /// Capture screenshot and submit feedback in one operation. - /// - /// TODO-2: Implement using UIApplication.shared.windows or UIScreen - public func captureAndSubmit( - type: BLFeedbackType, - title: String, - body: String? = nil - ) async throws -> BLFeedbackResponse { - throw BLFeedbackError.notImplemented( - "captureAndSubmit not yet implemented.\n\n" + - "To implement:\n" + - "1. Use UIGraphicsImageRenderer or UIScreen to capture\n" + - "2. Convert UIImage to Data (PNG/JPEG)\n" + - "3. Call submitFeedback with captured data\n\n" + - "Example:\n" + - "let window = UIApplication.shared.windows.first\n" + - "UIGraphicsBeginImageContext(window.bounds.size)\n" + - "window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)\n" + - "let image = UIGraphicsGetImageFromCurrentImageContext()\n" + - "UIGraphicsEndImageContext()" - ) - } - - // MARK: - Screenshot Capture - - /// Capture current screen as UIImage. - /// - /// TODO-2: Full implementation - public func captureScreen() async throws -> UIImage { - throw BLFeedbackError.notImplemented( - "captureScreen not yet implemented. Use UIScreen or UIApplication.shared.windows" - ) - } - - /// Capture specific UIView as UIImage. - /// - /// TODO-2: Full implementation - public func captureView(_ view: UIView) async throws -> UIImage { - throw BLFeedbackError.notImplemented( - "captureView not yet implemented. Use UIGraphicsImageRenderer or drawHierarchy" - ) - } - - // MARK: - Private - - private func generateSASURL(contentType: BLScreenshotContentType) async throws -> BLFeedbackSASResponse { - let body = ["contentType": contentType.rawValue] - return try await client.request( - path: "/api/feedback/sas", - method: "POST", - body: body, - responseType: BLFeedbackSASResponse.self - ) - } - - private func uploadScreenshot(data: Data, sasURL: String, contentType: String) async throws { - guard let url = URL(string: sasURL) else { - throw BLNetworkError.invalidURL(sasURL) - } - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue(contentType, forHTTPHeaderField: "Content-Type") - request.setValue("BlockBlob", forHTTPHeaderField: "x-ms-blob-type") - request.httpBody = data - request.timeoutInterval = 60 - - let (_, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse, - (200...299).contains(http.statusCode) else { - throw BLNetworkError.httpError( - statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, - message: "Screenshot upload failed" - ) - } - } -} - -/// Errors specific to feedback operations. -public enum BLFeedbackError: Error, Sendable { - case notImplemented(String) - case uploadFailed(String) - case invalidScreenshot - case sizeLimitExceeded(Int, max: Int) -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift deleted file mode 100644 index 0260d61..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLFieldEncrypt.swift +++ /dev/null @@ -1,277 +0,0 @@ -// ── Field Encryption ──────────────────────────────────────── -// AES-256-GCM field-level encryption compatible with @bytelyst/field-encrypt (TypeScript). -// Produces and consumes the same EncryptedField JSON structure across all platforms. -// Uses Apple CryptoKit — available on iOS 13+, macOS 10.15+, watchOS 6+. - -import CryptoKit -import Foundation - -// MARK: - EncryptedField Model - -/// Encrypted field structure — wire-compatible with @bytelyst/field-encrypt (TypeScript). -/// All byte arrays are hex-encoded strings for JSON serialization. -public struct BLEncryptedField: Codable, Sendable, Equatable { - /// Sentinel — always `true` for encrypted fields. - public let __encrypted: Bool - /// Schema version (currently 1). - public let v: Int - /// Algorithm identifier. - public let alg: String - /// Ciphertext (hex-encoded). - public let ct: String - /// Initialization vector (hex-encoded, 12 bytes = 24 hex chars). - public let iv: String - /// GCM authentication tag (hex-encoded, 16 bytes = 32 hex chars). - public let tag: String - /// DEK identifier — identifies which key was used for encryption. - public let dekId: String - - public init(ct: String, iv: String, tag: String, dekId: String) { - self.__encrypted = true - self.v = 1 - self.alg = "aes-256-gcm" - self.ct = ct - self.iv = iv - self.tag = tag - self.dekId = dekId - } -} - -// MARK: - BLFieldEncrypt - -/// AES-256-GCM field-level encryption. -/// -/// Produces `BLEncryptedField` objects that are wire-compatible with the -/// TypeScript `@bytelyst/field-encrypt` package. Backends and native clients -/// can encrypt/decrypt the same fields interchangeably. -/// -/// Usage: -/// ```swift -/// let key = BLFieldEncrypt.generateKey() -/// let encrypted = try BLFieldEncrypt.encrypt("sensitive data", key: key, dekId: "dek_user1_notes") -/// let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) -/// ``` -public enum BLFieldEncrypt { - - /// AES-256-GCM key size in bytes. - public static let keySize = 32 - - /// GCM nonce (IV) size in bytes. - private static let nonceSize = 12 - - // MARK: - Encrypt - - /// Encrypt a plaintext string with AES-256-GCM. - /// - /// - Parameters: - /// - plaintext: UTF-8 string to encrypt. - /// - key: 32-byte symmetric key. - /// - dekId: DEK identifier stored in the output for key lookup on decrypt. - /// - aad: Optional additional authenticated data (e.g., "userId:context"). - /// - Returns: `BLEncryptedField` with hex-encoded ciphertext, IV, and tag. - /// - Throws: `BLFieldEncryptError` if key size is wrong or encryption fails. - public static func encrypt( - _ plaintext: String, - key: SymmetricKey, - dekId: String, - aad: String? = nil - ) throws -> BLEncryptedField { - guard key.bitCount == keySize * 8 else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) - } - - let plaintextData = Data(plaintext.utf8) - let nonce = AES.GCM.Nonce() - - let sealedBox: AES.GCM.SealedBox - if let aad = aad { - sealedBox = try AES.GCM.seal( - plaintextData, - using: key, - nonce: nonce, - authenticating: Data(aad.utf8) - ) - } else { - sealedBox = try AES.GCM.seal(plaintextData, using: key, nonce: nonce) - } - - return BLEncryptedField( - ct: sealedBox.ciphertext.hexString, - iv: Data(sealedBox.nonce).hexString, - tag: sealedBox.tag.hexString, - dekId: dekId - ) - } - - // MARK: - Decrypt - - /// Decrypt a `BLEncryptedField` back to plaintext. - /// - /// - Parameters: - /// - field: Encrypted field object (from Cosmos DB or API response). - /// - key: 32-byte symmetric key (must match the key used to encrypt). - /// - aad: Optional AAD (must match the AAD used during encryption). - /// - Returns: Decrypted UTF-8 string. - /// - Throws: `BLFieldEncryptError` if decryption or authentication fails. - public static func decrypt( - _ field: BLEncryptedField, - key: SymmetricKey, - aad: String? = nil - ) throws -> String { - guard key.bitCount == keySize * 8 else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: key.bitCount / 8) - } - - guard let ctData = Data(hexString: field.ct), - let ivData = Data(hexString: field.iv), - let tagData = Data(hexString: field.tag) else { - throw BLFieldEncryptError.invalidHexEncoding - } - - let nonce = try AES.GCM.Nonce(data: ivData) - let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ctData, tag: tagData) - - let decryptedData: Data - if let aad = aad { - decryptedData = try AES.GCM.open(sealedBox, using: key, authenticating: Data(aad.utf8)) - } else { - decryptedData = try AES.GCM.open(sealedBox, using: key) - } - - guard let plaintext = String(data: decryptedData, encoding: .utf8) else { - throw BLFieldEncryptError.utf8DecodingFailed - } - - return plaintext - } - - // MARK: - Key Generation - - /// Generate a random 32-byte AES-256 symmetric key. - public static func generateKey() -> SymmetricKey { - SymmetricKey(size: .bits256) - } - - /// Create a `SymmetricKey` from a hex-encoded string (64 hex chars = 32 bytes). - public static func keyFromHex(_ hex: String) throws -> SymmetricKey { - guard let data = Data(hexString: hex), data.count == keySize else { - throw BLFieldEncryptError.invalidKeySize(expected: keySize, actual: hex.count / 2) - } - return SymmetricKey(data: data) - } - - // MARK: - Keychain Key Derivation - - /// Get or create a persistent encryption key in the Keychain. - /// - /// On first call, generates a random 32-byte AES-256 key and stores it - /// as a hex string in the Keychain. On subsequent calls, loads the - /// existing key. This provides a stable per-device DEK for client-side - /// encryption without requiring the backend to provision keys. - /// - /// - Parameters: - /// - service: Keychain service identifier (typically the app's bundle ID). - /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). - /// - Returns: A 32-byte `SymmetricKey` backed by Keychain storage. - /// - Throws: `BLFieldEncryptError.invalidHexEncoding` if stored key is corrupt. - public static func getOrCreateKey(service: String, account: String = "field_encrypt_dek") throws -> SymmetricKey { - if let existingHex = BLKeychain.read(service: service, key: account) { - return try keyFromHex(existingHex) - } - - let newKey = generateKey() - let hex = newKey.withUnsafeBytes { Data($0).hexString } - BLKeychain.save(service: service, key: account, value: hex) - return newKey - } - - /// Load an existing encryption key from the Keychain without creating one. - /// - /// - Parameters: - /// - service: Keychain service identifier. - /// - account: Keychain account key (e.g., `"field_encrypt_dek"`). - /// - Returns: The stored `SymmetricKey`, or `nil` if none exists. - public static func loadKey(service: String, account: String = "field_encrypt_dek") -> SymmetricKey? { - guard let hex = BLKeychain.read(service: service, key: account) else { return nil } - return try? keyFromHex(hex) - } - - /// Delete the stored encryption key from the Keychain. - /// - /// - Parameters: - /// - service: Keychain service identifier. - /// - account: Keychain account key. - /// - Returns: `true` if the key was deleted or didn't exist. - @discardableResult - public static func deleteKey(service: String, account: String = "field_encrypt_dek") -> Bool { - BLKeychain.delete(service: service, key: account) - } - - // MARK: - Type Guard - - /// Check if a JSON value represents an encrypted field. - /// Compatible with the TypeScript `isEncryptedField()` type guard. - public static func isEncrypted(_ value: Any?) -> Bool { - if let dict = value as? [String: Any], - let sentinel = dict["__encrypted"] as? Bool, - sentinel == true, - dict["v"] != nil, - dict["alg"] != nil, - dict["ct"] != nil, - dict["iv"] != nil, - dict["tag"] != nil, - dict["dekId"] != nil { - return true - } - return false - } - - /// Check if a `Codable` value is a `BLEncryptedField`. - public static func isEncrypted(_ field: BLEncryptedField?) -> Bool { - field?.__encrypted == true - } -} - -// MARK: - Errors - -public enum BLFieldEncryptError: LocalizedError { - case invalidKeySize(expected: Int, actual: Int) - case invalidHexEncoding - case utf8DecodingFailed - - public var errorDescription: String? { - switch self { - case .invalidKeySize(let expected, let actual): - return "AES-256-GCM requires a \(expected)-byte key, got \(actual)" - case .invalidHexEncoding: - return "Failed to decode hex-encoded field data" - case .utf8DecodingFailed: - return "Decrypted data is not valid UTF-8" - } - } -} - -// MARK: - Hex Helpers - -extension Data { - /// Hex-encode data to a lowercase string. - var hexString: String { - map { String(format: "%02x", $0) }.joined() - } - - /// Initialize Data from a hex-encoded string. - init?(hexString: String) { - let hex = hexString.lowercased() - guard hex.count.isMultiple(of: 2) else { return nil } - - var data = Data(capacity: hex.count / 2) - var index = hex.startIndex - while index < hex.endIndex { - let nextIndex = hex.index(index, offsetBy: 2) - guard let byte = UInt8(hex[index.. Void - let onTap: () async -> Void - - var body: some View { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(message.title) - .font(.headline) - - if !message.body.isEmpty { - Text(message.body) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(2) - } - - if message.ctaText != nil { - Text("Tap to open") - .font(.caption) - .foregroundColor(.blue) - } - } - - Spacer() - - if message.dismissible { - Button(action: { Task { await onDismiss() } }) { - Image(systemName: "xmark") - .foregroundColor(.secondary) - .padding(8) - } - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 12) - .fill(backgroundColor) - .shadow(radius: 2) - ) - .contentShape(Rectangle()) - .onTapGesture { - Task { await onTap() } - } - } - - private var backgroundColor: Color { - switch message.priority { - case .urgent: - return Color.red.opacity(0.1) - case .high: - return Color.orange.opacity(0.1) - default: - return Color(.systemBackground) - } - } -} - -@available(iOS 15.0, *) -public struct BLBroadcastModal: View { - @ObservedObject var client: BLBroadcastClient - @State private var currentMessage: BLInAppMessage? - @State private var isPresented = false - - public init(client: BLBroadcastClient) { - self.client = client - } - - public var body: some View { - EmptyView() - .sheet(isPresented: $isPresented) { - if let message = currentMessage { - ModalContent( - message: message, - onDismiss: { await dismissMessage() }, - onAction: { await handleAction() } - ) - } - } - .task { - startPolling() - } - } - - private func startPolling() { - client.startPolling(interval: 30) { messages in - let modalMessages = messages.filter { - $0.status == .unread && ($0.style == .modal || $0.style == .fullscreen) - } - if let first = modalMessages.first, self.currentMessage == nil { - self.currentMessage = first - self.isPresented = true - } - } - } - - private func dismissMessage() async { - if let message = currentMessage { - try? await client.markDismissed(messageId: message.id) - } - isPresented = false - currentMessage = nil - } - - private func handleAction() async { - if let message = currentMessage { - _ = try? await client.trackClick(messageId: message.id) - if let urlString = message.ctaUrl, let url = URL(string: urlString) { - await UIApplication.shared.open(url) - } - try? await client.markRead(messageId: message.id) - } - isPresented = false - currentMessage = nil - } -} - -@available(iOS 15.0, *) -struct ModalContent: View { - let message: BLInAppMessage - let onDismiss: () async -> Void - let onAction: () async -> Void - - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 20) { - Text(message.title) - .font(.title2.bold()) - - if !message.body.isEmpty { - Text(message.body) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - - if message.ctaText != nil { - Button(action: { Task { await onAction() } }) { - Text(message.ctaText!) - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .cornerRadius(12) - } - } - } - .padding() - } - .navigationBarItems( - trailing: message.dismissible ? Button("Close") { - Task { await onDismiss() } - } : nil - ) - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift deleted file mode 100644 index eec56c9..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLKeychain.swift +++ /dev/null @@ -1,54 +0,0 @@ -// ── Keychain Helper ─────────────────────────────────────────── -// Generic Keychain CRUD for storing auth tokens securely. -// Service identifier is configurable per product via BLPlatformConfig.bundleId. - -import Foundation -import Security - -public enum BLKeychain { - - /// Save a string value to the Keychain. - @discardableResult - public static func save(service: String, key: String, value: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - delete(service: service, key: key) - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - ] - - return SecItemAdd(query as CFDictionary, nil) == errSecSuccess - } - - /// Read a string value from the Keychain. - public static func read(service: String, key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - /// Delete a value from the Keychain. - @discardableResult - public static func delete(service: String, key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift deleted file mode 100644 index 736965d..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLKillSwitchClient.swift +++ /dev/null @@ -1,60 +0,0 @@ -// ── Kill Switch Client ────────────────────────────────────── -// Checks platform-service kill switch at app launch. -// If the app is disabled server-side, surfaces a maintenance message. -// Fails open — network errors allow the app to run. - -import Foundation - -/// Generic kill switch client for all ByteLyst iOS apps. -/// Checks GET /api/settings/kill-switch?productId=X at launch. -public final class BLKillSwitchClient { - - private let config: BLPlatformConfig - - /// Whether the app is disabled by the server. - public private(set) var isDisabled = false - - /// Maintenance message from the server (empty if not disabled). - public private(set) var maintenanceMessage = "" - - public init(config: BLPlatformConfig) { - self.config = config - } - - /// Check kill switch status. Non-blocking — defaults to enabled on failure (fail open). - public func check() async { - guard let url = URL(string: "\(config.baseURL)/api/settings/kill-switch?productId=\(config.productId)") else { return } - - var request = URLRequest(url: url) - request.timeoutInterval = 5 - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } - - struct KillSwitchResponse: Codable { - let enabled: Bool? - let disabled: Bool? - let message: String? - } - - let result = try JSONDecoder().decode(KillSwitchResponse.self, from: data) - - // Support both `enabled: false` and `disabled: true` patterns - if result.disabled == true || result.enabled == false { - isDisabled = true - maintenanceMessage = result.message ?? "\(config.productId) is temporarily unavailable for maintenance." - } - } catch { - // Network error — allow the app to run (fail open) - } - } - - /// Reset the kill switch state (e.g. for retry). - public func reset() { - isDisabled = false - maintenanceMessage = "" - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift deleted file mode 100644 index f6926f1..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLLicenseClient.swift +++ /dev/null @@ -1,104 +0,0 @@ -// ── License Client ────────────────────────────────────────── -// Generic license key activation via platform-service. -// Flow: enter key → POST /api/licenses/activate → receive tokens. -// Product apps configure with BLPlatformConfig. - -import Foundation - -/// License information returned from platform-service. -public struct BLLicenseInfo: Codable, Sendable { - public let key: String - public let plan: String - public let status: String - public let devicesUsed: Int - public let maxDevices: Int - public let expiresAt: String? - - public init(key: String, plan: String, status: String, devicesUsed: Int, maxDevices: Int, expiresAt: String?) { - self.key = key - self.plan = plan - self.status = status - self.devicesUsed = devicesUsed - self.maxDevices = maxDevices - self.expiresAt = expiresAt - } -} - -/// Activation result containing tokens + license info. -public struct BLActivationResult: Sendable { - public let accessToken: String - public let refreshToken: String - public let license: BLLicenseInfo -} - -/// Generic license client for all ByteLyst iOS apps. -/// Handles license key activation and status checking via platform-service. -public final class BLLicenseClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - public init(config: BLPlatformConfig, client: BLPlatformClient) { - self.config = config - self.client = client - } - - // MARK: - Activate - - /// Activate a license key on this device. - /// Returns activation result with tokens and license info. - public func activate(key: String, deviceId: String, deviceName: String) async throws -> BLActivationResult { - let body: [String: String] = [ - "key": key.uppercased().trimmingCharacters(in: .whitespaces), - "deviceId": deviceId, - "deviceName": deviceName, - "platform": config.platform, - ] - - let (data, _) = try await client.rawRequest(path: "/api/licenses/activate", method: "POST", body: body) - - struct ActivateResponse: Codable { - let accessToken: String - let refreshToken: String - let license: LicenseDoc - } - struct LicenseDoc: Codable { - let key: String - let plan: String - let status: String - let deviceIds: [String] - let maxDevices: Int - let expiresAt: String? - let userId: String - } - - let result = try JSONDecoder().decode(ActivateResponse.self, from: data) - - let info = BLLicenseInfo( - key: result.license.key, - plan: result.license.plan, - status: result.license.status, - devicesUsed: result.license.deviceIds.count, - maxDevices: result.license.maxDevices, - expiresAt: result.license.expiresAt - ) - - return BLActivationResult( - accessToken: result.accessToken, - refreshToken: result.refreshToken, - license: info - ) - } - - // MARK: - Status - - /// Check license status without activating. - public func checkStatus(key: String) async throws -> BLLicenseInfo { - let trimmedKey = key.uppercased().trimmingCharacters(in: .whitespaces) - let encodedKey = trimmedKey.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? trimmedKey - return try await client.request( - path: "/api/licenses/status/\(encodedKey)", - responseType: BLLicenseInfo.self - ) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift deleted file mode 100644 index 8017f83..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformClient.swift +++ /dev/null @@ -1,234 +0,0 @@ -// ── Platform HTTP Client ───────────────────────────────────── -// Generic URLSession wrapper with auth header injection, x-request-id, -// timeout, and product ID header. Used by all BL* services. - -import Foundation - -public final class BLPlatformClient: @unchecked Sendable { - - public let config: BLPlatformConfig - private let session: URLSession - private let encoder: JSONEncoder - private let decoder: JSONDecoder - - /// Auth token injected by BLAuthClient after login/refresh. - private var _authToken: String? - private let tokenLock = NSLock() - - public var authToken: String? { - get { tokenLock.lock(); defer { tokenLock.unlock() }; return _authToken } - set { tokenLock.lock(); defer { tokenLock.unlock() }; _authToken = newValue } - } - - public init(config: BLPlatformConfig, timeoutSeconds: TimeInterval = 15) { - self.config = config - - let urlConfig = URLSessionConfiguration.default - urlConfig.timeoutIntervalForRequest = timeoutSeconds - urlConfig.waitsForConnectivity = true - session = URLSession(configuration: urlConfig) - - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - } - - /// Test-only initializer accepting a custom URLSessionConfiguration - /// so callers can inject MockURLProtocol for intercepting requests. - public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { - self.config = config - session = URLSession(configuration: sessionConfiguration) - - encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - } - - // MARK: - Public Request Methods - - /// Perform an authenticated request and decode the response. - public func request( - path: String, - method: String = "GET", - body: (any Encodable)? = nil, - responseType: T.Type - ) async throws -> T { - let (data, _) = try await rawRequest(path: path, method: method, body: body) - return try decoder.decode(T.self, from: data) - } - - /// Perform an authenticated request, returning raw (Data, HTTPURLResponse). - public func rawRequest( - path: String, - method: String = "GET", - body: (any Encodable)? = nil - ) async throws -> (Data, HTTPURLResponse) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body { - request.httpBody = try encoder.encode(body) - } - - let (data, response) = try await session.data(for: request) - - guard let http = response as? HTTPURLResponse else { - throw BLNetworkError.invalidResponse - } - - guard (200...299).contains(http.statusCode) else { - // Try to extract server error message - let message: String? = { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let msg = json["message"] as? String { return msg } - return nil - }() - throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) - } - - return (data, http) - } - - /// Perform an authenticated request with pre-encoded JSON body data. - /// Used by passkey methods that need to send raw JSON (e.g. from JSONSerialization). - public func rawRequest( - path: String, - method: String = "GET", - rawBody: Data? = nil - ) async throws -> (Data, HTTPURLResponse) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - request.httpBody = rawBody - - let (data, response) = try await session.data(for: request) - - guard let http = response as? HTTPURLResponse else { - throw BLNetworkError.invalidResponse - } - - guard (200...299).contains(http.statusCode) else { - let message: String? = { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let msg = json["message"] as? String { return msg } - return nil - }() - throw BLNetworkError.httpError(statusCode: http.statusCode, message: message) - } - - return (data, http) - } - - /// Fire-and-forget POST (used by telemetry — errors silently ignored). - public func fireAndForget(path: String, body: Data) { - guard let url = URL(string: "\(config.baseURL)\(path)") else { return } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - request.timeoutInterval = 10 - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - session.dataTask(with: request) { _, _, _ in }.resume() - } - - // MARK: - Request Builder - - /// Build an authenticated URLRequest (used by BLBroadcastClient, BLSurveyClient). - public func buildRequest( - path: String, - method: String = "GET", - body: [String: Any]? = nil - ) throws -> URLRequest { - guard let url = URL(string: "\(config.baseURL)\(path)") else { - throw BLNetworkError.invalidURL(path) - } - - var request = URLRequest(url: url) - request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue(config.productId, forHTTPHeaderField: "X-Product-Id") - request.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-Id") - - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - - if let body { - request.httpBody = try JSONSerialization.data(withJSONObject: body) - } - - return request - } - - // MARK: - Encoder/Decoder Access - - public var jsonEncoder: JSONEncoder { encoder } - public var jsonDecoder: JSONDecoder { decoder } -} - -// MARK: - Errors - -/// Legacy alias used by BLBroadcastClient, BLSurveyClient, BLDeepLinkRouter. -public enum BLPlatformError: LocalizedError { - case requestFailed(String) - - public var errorDescription: String? { - switch self { - case .requestFailed(let msg): return msg - } - } -} - -public enum BLNetworkError: LocalizedError { - case invalidURL(String) - case invalidResponse - case httpError(statusCode: Int, message: String?) - case notAuthenticated - - public var errorDescription: String? { - switch self { - case .invalidURL(let path): return "Invalid URL: \(path)" - case .invalidResponse: return "Invalid server response" - case .httpError(let code, let msg): return msg ?? "Server error (\(code))" - case .notAuthenticated: return "Not signed in" - } - } - - public var statusCode: Int? { - if case .httpError(let code, _) = self { return code } - return nil - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift deleted file mode 100644 index c0c7c61..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLPlatformConfig.swift +++ /dev/null @@ -1,64 +0,0 @@ -// ── Platform Configuration ─────────────────────────────────── -// Product-specific config that every BL* service reads from. -// Each app creates ONE config at launch and passes it to all services. - -import Foundation - -/// Configuration for all ByteLyst platform services. -/// Create one instance at app launch and inject into BLTelemetryClient, BLAuthClient, etc. -public struct BLPlatformConfig { - /// Product identifier (e.g. "chronomind", "lysnrai", "peakpulse", "nomgap", "mindlyst"). - public let productId: String - - /// Platform-service base URL (e.g. "https://api.chronomind.app" or "http://localhost:4003/api"). - public let baseURL: String - - /// Platform string sent in telemetry (e.g. "ios", "watchos", "macos"). - public let platform: String - - /// Channel string sent in telemetry (e.g. "native", "mobile_app"). - public let channel: String - - /// Bundle ID used as Keychain service identifier. - public let bundleId: String - - /// App Group ID for sharing data between app and extensions (optional). - public let appGroupId: String? - - public init( - productId: String, - baseURL: String, - platform: String = "ios", - channel: String = "native", - bundleId: String, - appGroupId: String? = nil - ) { - self.productId = productId - self.baseURL = baseURL - self.platform = platform - self.channel = channel - self.bundleId = bundleId - self.appGroupId = appGroupId - } - - /// Convenience: read PLATFORM_SERVICE_URL from Info.plist, fall back to provided default. - public static func fromInfoPlist( - productId: String, - defaultBaseURL: String, - platform: String = "ios", - channel: String = "native", - bundleId: String, - appGroupId: String? = nil - ) -> BLPlatformConfig { - let url = Bundle.main.object(forInfoDictionaryKey: "PLATFORM_SERVICE_URL") as? String - ?? defaultBaseURL - return BLPlatformConfig( - productId: productId, - baseURL: url, - platform: platform, - channel: channel, - bundleId: bundleId, - appGroupId: appGroupId - ) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift deleted file mode 100644 index dc31f23..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyClient.swift +++ /dev/null @@ -1,369 +0,0 @@ -// ── Survey Client ──────────────────────────────────────── -// In-app survey client for iOS/watchOS/macOS. -// Part of ByteLystPlatformSDK. - -import Foundation - -/// Survey question types. -public enum BLSurveyQuestionType: String, Codable, Sendable { - case singleChoice = "single_choice" - case multipleChoice = "multiple_choice" - case rating = "rating" - case nps = "nps" - case textShort = "text_short" - case textLong = "text_long" - case dropdown = "dropdown" - case scale = "scale" - case ranking = "ranking" -} - -/// Survey status. -public enum BLSurveyStatus: String, Codable, Sendable { - case draft = "draft" - case active = "active" - case paused = "paused" - case closed = "closed" -} - -/// Represents a survey question option. -public struct BLSurveyOption: Codable, Sendable, Identifiable { - public let id: String - public let text: String - public let emoji: String? -} - -/// Represents a survey question. -public struct BLSurveyQuestion: Codable, Sendable, Identifiable { - public let id: String - public let type: BLSurveyQuestionType - public let text: String - public let description: String? - public let required: Bool - public let options: [BLSurveyOption]? - public let minLength: Int? - public let maxLength: Int? - public let minValue: Int? - public let maxValue: Int? -} - -/// Represents an active survey for display. -public struct BLActiveSurvey: Codable, Sendable, Identifiable { - public let id: String - public let title: String - public let description: String? - public let questions: [BLSurveyQuestion] - public let incentive: BLSurveyIncentive? - public let displayTrigger: BLSurveyTrigger -} - -/// Survey incentive. -public struct BLSurveyIncentive: Codable, Sendable { - public let type: String - public let amount: Int -} - -/// Survey display trigger. -public struct BLSurveyTrigger: Codable, Sendable { - public let type: String - public let seconds: Int? - public let eventName: String? - public let pagePattern: String? -} - -/// Survey answer types. -public enum BLSurveyAnswer: Codable, Sendable { - case singleChoice(optionId: String) - case multipleChoice(optionIds: [String]) - case rating(value: Int) - case nps(value: Int) - case text(value: String) - case ranking(rankedOptionIds: [String]) - - public var type: String { - switch self { - case .singleChoice: return "single_choice" - case .multipleChoice: return "multiple_choice" - case .rating: return "rating" - case .nps: return "nps" - case .text: return "text" - case .ranking: return "ranking" - } - } -} - -/// Represents a survey response. -public struct BLSurveyResponse: Codable, Sendable { - public let id: String - public let surveyId: String - public let userId: String - public var answers: [String: BLSurveyAnswer] - public var currentQuestionIndex: Int - public let startedAt: String - public var completedAt: String? - public var isComplete: Bool - public var incentiveClaimed: Bool - public var incentiveClaimedAt: String? - public let createdAt: String - public let updatedAt: String -} - -/// Survey client for managing in-app surveys. -@available(iOS 15.0, macOS 12.0, watchOS 8.0, *) -public class BLSurveyClient: ObservableObject { - private let platformClient: BLPlatformClient - private var pollTask: Task? - private var cachedResponses: [String: BLSurveyResponse] = [:] - - public init(platformClient: BLPlatformClient) { - self.platformClient = platformClient - } - - /// Get active survey for the current user (if any). - public func getActiveSurvey() async throws -> BLActiveSurvey? { - let request = try platformClient.buildRequest(path: "/surveys/active") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") - } - - let result = try JSONDecoder().decode(ActiveSurveyResponse.self, from: data) - return result.survey - } - - /// Start a survey session. - public func startSurvey(surveyId: String) async throws -> BLSurveyResponse { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/start", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to start survey") - } - - let result = try JSONDecoder().decode(StartSurveyResponse.self, from: data) - - // Build and cache the response - let surveyResponse = BLSurveyResponse( - id: result.responseId, - surveyId: surveyId, - userId: "", // Will be filled by server - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: result.startedAt, - completedAt: nil, - isComplete: false, - incentiveClaimed: false, - incentiveClaimedAt: nil, - createdAt: result.startedAt, - updatedAt: result.startedAt - ) - - cachedResponses[surveyId] = surveyResponse - return surveyResponse - } - - /// Submit an answer to a survey question. - public func submitAnswer( - surveyId: String, - questionId: String, - answer: BLSurveyAnswer - ) async throws -> BLSurveyResponse { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/response", - method: "POST", - body: [ - "questionId": questionId, - "answer": [ - "type": answer.type, - "value": encodeAnswerValue(answer) - ] - ] - ) - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to submit answer") - } - - let result = try JSONDecoder().decode(SubmitAnswerResponse.self, from: data) - - // Update cache - if var cached = cachedResponses[surveyId] { - cached.answers = result.answers - cached.currentQuestionIndex = result.currentQuestionIndex - cachedResponses[surveyId] = cached - } - - // Return updated response - return BLSurveyResponse( - id: result.responseId, - surveyId: surveyId, - userId: "", - answers: result.answers, - currentQuestionIndex: result.currentQuestionIndex, - startedAt: "", - completedAt: nil, - isComplete: false, - incentiveClaimed: false, - incentiveClaimedAt: nil, - createdAt: "", - updatedAt: Date().ISO8601Format() - ) - } - - /// Complete a survey. - public func completeSurvey(surveyId: String) async throws -> SurveyCompletionResult { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/complete", - method: "POST" - ) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to complete survey") - } - - let result = try JSONDecoder().decode(SurveyCompletionResult.self, from: data) - - // Clear cache on completion - cachedResponses.removeValue(forKey: surveyId) - - return result - } - - /// Dismiss a survey (won't show again). - public func dismissSurvey(surveyId: String) async throws { - let request = try platformClient.buildRequest( - path: "/surveys/\(surveyId)/dismiss", - method: "POST" - ) - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw BLPlatformError.requestFailed("Failed to dismiss survey") - } - - // Clear cache - cachedResponses.removeValue(forKey: surveyId) - } - - /// Get cached response for a survey. - public func getCachedResponse(surveyId: String) -> BLSurveyResponse? { - return cachedResponses[surveyId] - } - - /// Start polling for eligible surveys. - public func startPolling( - interval: TimeInterval = 60, - onUpdate: @escaping (BLActiveSurvey?) -> Void - ) { - stopPolling() - - pollTask = Task { - while !Task.isCancelled { - do { - let survey = try await getActiveSurvey() - onUpdate(survey) - } catch { - // Silently ignore polling errors - } - - try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - } - } - - /// Stop polling for surveys. - public func stopPolling() { - pollTask?.cancel() - pollTask = nil - } -} - -// MARK: - Survey Completion Result - -public struct SurveyCompletionResult: Codable, Sendable { - public let success: Bool - public let timeSpentSeconds: Int - public let incentiveClaimed: Bool -} - -// MARK: - Response Types - -private struct ActiveSurveyResponse: Codable { - let survey: BLActiveSurvey? -} - -private struct StartSurveyResponse: Codable { - let responseId: String - let startedAt: String - let currentQuestionIndex: Int - let answers: [String: BLSurveyAnswer] -} - -private struct SubmitAnswerResponse: Codable { - let responseId: String - let currentQuestionIndex: Int - let answers: [String: BLSurveyAnswer] -} - -// MARK: - Helper Functions - -private func encodeAnswerValue(_ answer: BLSurveyAnswer) -> AnyCodable { - switch answer { - case .singleChoice(let optionId): - return AnyCodable(optionId) - case .multipleChoice(let optionIds): - return AnyCodable(optionIds) - case .rating(let value), .nps(let value): - return AnyCodable(value) - case .text(let value): - return AnyCodable(value) - case .ranking(let rankedOptionIds): - return AnyCodable(rankedOptionIds) - } -} - -/// Type-erased codable wrapper for encoding heterogeneous types. -private struct AnyCodable: Codable { - private let value: Any - - init(_ value: Any) { - self.value = value - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if let string = value as? String { - try container.encode(string) - } else if let int = value as? Int { - try container.encode(int) - } else if let array = value as? [String] { - try container.encode(array) - } else { - try container.encode(String(describing: value)) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let string = try? container.decode(String.self) { - value = string - } else if let int = try? container.decode(Int.self) { - value = int - } else if let array = try? container.decode([String].self) { - value = array - } else { - value = "" - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift deleted file mode 100644 index 57fb887..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSurveyUI.swift +++ /dev/null @@ -1,592 +0,0 @@ -import SwiftUI - -/** - * Survey Modal — SwiftUI component for displaying and completing surveys. - * Part of ByteLystPlatformSDK. - */ -@available(iOS 15.0, *) -public struct BLSurveyModal: View { - @ObservedObject var client: BLSurveyClient - @State private var survey: BLActiveSurvey? - @State private var currentQuestionIndex = 0 - @State private var answers: [String: BLSurveyAnswer] = [:] - @State private var isComplete = false - @State private var showCompletion = false - @State private var isPresented = false - - // Local state for question answers - @State private var selectedOption: String? - @State private var selectedOptions: Set = [] - @State private var ratingValue: Int = 0 - @State private var textAnswer: String = "" - @State private var rankingOrder: [String] = [] - - public init(client: BLSurveyClient) { - self.client = client - } - - public var body: some View { - EmptyView() - .sheet(isPresented: $isPresented) { - surveyContent - } - .task { - await checkForSurvey() - startPolling() - } - } - - @ViewBuilder - private var surveyContent: some View { - if showCompletion { - CompletionView( - survey: survey, - onDismiss: { dismissSurvey() } - ) - } else if let survey = survey, currentQuestionIndex < survey.questions.count { - let question = survey.questions[currentQuestionIndex] - QuestionView( - survey: survey, - question: question, - questionIndex: currentQuestionIndex, - totalQuestions: survey.questions.count, - selectedOption: $selectedOption, - selectedOptions: $selectedOptions, - ratingValue: $ratingValue, - textAnswer: $textAnswer, - rankingOrder: $rankingOrder, - onSubmit: { await submitAnswer(question) }, - onSkip: { await skipQuestion() }, - onDismiss: { dismissSurvey() } - ) - } - } - - private func checkForSurvey() async { - do { - if let activeSurvey = try await client.getActiveSurvey() { - survey = activeSurvey - if !isPresented { - isPresented = true - } - } - } catch { - // Silently ignore - } - } - - private func startPolling() { - client.startPolling(interval: 60) { newSurvey in - if let newSurvey = newSurvey, self.survey == nil { - self.survey = newSurvey - self.isPresented = true - } - } - } - - private func submitAnswer(_ question: BLSurveyQuestion) async { - let answer: BLSurveyAnswer - - switch question.type { - case .singleChoice, .dropdown: - guard let value = selectedOption else { return } - answer = .singleChoice(optionId: value) - case .multipleChoice: - let values = Array(selectedOptions) - answer = .multipleChoice(optionIds: values) - case .rating, .scale: - answer = .rating(value: ratingValue) - case .nps: - answer = .nps(value: ratingValue) - case .textShort, .textLong: - answer = .text(value: textAnswer) - case .ranking: - answer = .ranking(rankedOptionIds: rankingOrder) - } - - do { - let response = try await client.submitAnswer(surveyId: survey!.id, questionId: question.id, answer: answer) - currentQuestionIndex = response.currentQuestionIndex - answers = response.answers - resetQuestionState() - - if currentQuestionIndex >= survey!.questions.count { - await completeSurvey() - } - } catch { - // Silently ignore submit errors - } - } - - private func skipQuestion() async { - let question = survey!.questions[currentQuestionIndex] - if !question.required { - // Skip by advancing without submitting - currentQuestionIndex += 1 - resetQuestionState() - - if currentQuestionIndex >= survey!.questions.count { - await completeSurvey() - } - } - } - - private func completeSurvey() async { - do { - let completion = try await client.completeSurvey(surveyId: survey!.id) - if completion.success { - isComplete = true - showCompletion = true - } - } catch { - // Silently ignore - } - } - - private func dismissSurvey() { - if let survey = survey { - Task { - try? await client.dismissSurvey(surveyId: survey.id) - } - } - isPresented = false - resetSurvey() - } - - private func resetSurvey() { - survey = nil - currentQuestionIndex = 0 - answers = [:] - isComplete = false - showCompletion = false - resetQuestionState() - } - - private func resetQuestionState() { - selectedOption = nil - selectedOptions = [] - ratingValue = 0 - textAnswer = "" - rankingOrder = [] - } -} - -@available(iOS 15.0, *) -struct QuestionView: View { - let survey: BLActiveSurvey - let question: BLSurveyQuestion - let questionIndex: Int - let totalQuestions: Int - - @Binding var selectedOption: String? - @Binding var selectedOptions: Set - @Binding var ratingValue: Int - @Binding var textAnswer: String - @Binding var rankingOrder: [String] - - let onSubmit: () async -> Void - let onSkip: () async -> Void - let onDismiss: () -> Void - - var body: some View { - NavigationView { - ScrollView { - VStack(spacing: 24) { - // Progress bar - ProgressView(value: Double(questionIndex + 1), total: Double(totalQuestions)) - .padding(.horizontal) - - Text("Question \(questionIndex + 1) of \(totalQuestions)") - .font(.caption) - .foregroundColor(.secondary) - - // Question text - VStack(alignment: .leading, spacing: 8) { - Text(question.text) - .font(.title3.bold()) - - if let description = question.description { - Text(description) - .font(.subheadline) - .foregroundColor(.secondary) - } - - if question.required { - Text("Required") - .font(.caption) - .foregroundColor(.red) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - - // Question input based on type - questionInput - - Spacer() - - // Action buttons - VStack(spacing: 12) { - Button(action: { Task { await onSubmit() } }) { - Text(isLast ? "Complete" : "Next") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(canSubmit ? Color.blue : Color.gray) - .cornerRadius(12) - } - .disabled(!canSubmit) - - if !question.required { - Button(action: { Task { await onSkip() } }) { - Text("Skip") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle(survey.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Dismiss") { - onDismiss() - } - } - } - } - } - - private var isLast: Bool { - questionIndex == totalQuestions - 1 - } - - private var canSubmit: Bool { - if !question.required { return true } - - switch question.type { - case .singleChoice, .dropdown: - return selectedOption != nil - case .multipleChoice: - return !selectedOptions.isEmpty - case .rating, .scale, .nps: - return ratingValue > 0 - case .textShort, .textLong: - return !textAnswer.isEmpty - case .ranking: - return rankingOrder.count == (question.options?.count ?? 0) - } - } - - @ViewBuilder - private var questionInput: some View { - switch question.type { - case .singleChoice, .dropdown: - SingleChoiceView( - options: question.options ?? [], - selected: $selectedOption - ) - case .multipleChoice: - MultipleChoiceView( - options: question.options ?? [], - selected: $selectedOptions - ) - case .rating, .scale, .nps: - RatingView( - minValue: question.minValue ?? (question.type == .nps ? 0 : 1), - maxValue: question.maxValue ?? (question.type == .nps ? 10 : 5), - rating: $ratingValue - ) - case .textShort, .textLong: - TextAnswerView( - text: $textAnswer, - isLong: question.type == .textLong, - minLength: question.minLength, - maxLength: question.maxLength - ) - case .ranking: - RankingView( - options: question.options ?? [], - order: $rankingOrder - ) - } - } -} - -// MARK: - Question Type Views - -@available(iOS 15.0, *) -struct SingleChoiceView: View { - let options: [BLSurveyOption] - @Binding var selected: String? - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - Button(action: { selected = option.id }) { - HStack { - Text(option.emoji ?? "") - Text(option.text) - .foregroundColor(.primary) - Spacer() - if selected == option.id { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.blue) - } else { - Image(systemName: "circle") - .foregroundColor(.secondary) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(selected == option.id ? Color.blue.opacity(0.1) : Color(.systemGray6)) - ) - } - } - } - .padding(.horizontal) - } -} - -@available(iOS 15.0, *) -struct MultipleChoiceView: View { - let options: [BLSurveyOption] - @Binding var selected: Set - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - Button(action: { toggleOption(option.id) }) { - HStack { - Text(option.emoji ?? "") - Text(option.text) - .foregroundColor(.primary) - Spacer() - if selected.contains(option.id) { - Image(systemName: "checkmark.square.fill") - .foregroundColor(.blue) - } else { - Image(systemName: "square") - .foregroundColor(.secondary) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: 8) - .fill(selected.contains(option.id) ? Color.blue.opacity(0.1) : Color(.systemGray6)) - ) - } - } - } - .padding(.horizontal) - } - - private func toggleOption(_ id: String) { - if selected.contains(id) { - selected.remove(id) - } else { - selected.insert(id) - } - } -} - -@available(iOS 15.0, *) -struct RatingView: View { - let minValue: Int - let maxValue: Int - @Binding var rating: Int - - var body: some View { - VStack(spacing: 16) { - HStack(spacing: 8) { - ForEach(minValue...maxValue, id: \.self) { value in - Button(action: { rating = value }) { - Text("\(value)") - .font(.headline) - .frame(width: 44, height: 44) - .background( - Circle() - .fill(rating == value ? Color.blue : Color(.systemGray5)) - ) - .foregroundColor(rating == value ? .white : .primary) - } - } - } - - HStack { - Text("Low") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text("High") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 32) - } - } -} - -@available(iOS 15.0, *) -struct TextAnswerView: View { - @Binding var text: String - let isLong: Bool - let minLength: Int? - let maxLength: Int? - - var body: some View { - VStack { - if isLong { - TextEditor(text: $text) - .frame(minHeight: 120) - .padding(8) - .background(Color(.systemGray6)) - .cornerRadius(8) - } else { - TextField("Your answer", text: $text) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - if let max = maxLength { - Text("\(text.count)/\(max)") - .font(.caption) - .foregroundColor(text.count > max ? .red : .secondary) - } - } - .padding(.horizontal) - } -} - -@available(iOS 15.0, *) -struct RankingView: View { - let options: [BLSurveyOption] - @Binding var order: [String] - - var body: some View { - VStack(spacing: 8) { - ForEach(options) { option in - HStack { - Text("\(order.firstIndex(of: option.id).map { "\($0 + 1)" } ?? "-")") - .font(.caption) - .frame(width: 24, height: 24) - .background( - Circle() - .fill(order.contains(option.id) ? Color.blue : Color(.systemGray5)) - ) - .foregroundColor(order.contains(option.id) ? .white : .secondary) - - Text(option.text) - - Spacer() - - HStack(spacing: 4) { - Button(action: { moveUp(option.id) }) { - Image(systemName: "arrow.up") - } - .disabled(!canMoveUp(option.id)) - - Button(action: { moveDown(option.id) }) { - Image(systemName: "arrow.down") - } - .disabled(!canMoveDown(option.id)) - - Button(action: { addToRanking(option.id) }) { - Image(systemName: "plus") - } - .disabled(order.contains(option.id)) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - .padding(.horizontal) - } - - private func canMoveUp(_ id: String) -> Bool { - guard let index = order.firstIndex(of: id), index > 0 else { return false } - return true - } - - private func canMoveDown(_ id: String) -> Bool { - guard let index = order.firstIndex(of: id), index < order.count - 1 else { return false } - return true - } - - private func moveUp(_ id: String) { - guard let index = order.firstIndex(of: id), index > 0 else { return } - order.swapAt(index, index - 1) - } - - private func moveDown(_ id: String) { - guard let index = order.firstIndex(of: id), index < order.count - 1 else { return } - order.swapAt(index, index + 1) - } - - private func addToRanking(_ id: String) { - if !order.contains(id) { - order.append(id) - } - } -} - -@available(iOS 15.0, *) -struct CompletionView: View { - let survey: BLActiveSurvey? - let onDismiss: () -> Void - - var body: some View { - NavigationView { - VStack(spacing: 24) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 64)) - .foregroundColor(.green) - - Text("Thank You!") - .font(.title.bold()) - - Text("Your feedback helps us improve.") - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - if let incentive = survey?.incentive { - HStack { - Image(systemName: "gift.fill") - .foregroundColor(.green) - Text("You've earned \(incentive.amount) \(incentive.type == "pro_days" ? "Pro Days" : "Credits")!") - .font(.headline) - .foregroundColor(.green) - } - .padding() - .background(Color.green.opacity(0.1)) - .cornerRadius(12) - } - - Spacer() - - Button(action: onDismiss) { - Text("Close") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .cornerRadius(12) - } - .padding(.horizontal) - } - .padding() - .navigationBarHidden(true) - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift deleted file mode 100644 index 3bc6a8b..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLSyncEngine.swift +++ /dev/null @@ -1,240 +0,0 @@ -// ── Sync Engine ────────────────────────────────────────────── -// Generic offline-first sync engine for ByteLyst iOS apps. -// Provides: offline queue, periodic sync, delta pull, batch push. -// Product apps supply a SyncAdapter with their DTO types and endpoints. -// -// This extracts the generic parts of ChronoMind's PlatformSyncManager -// so every product app gets offline sync without reimplementing the -// queue, timer, error handling, and conflict resolution plumbing. - -import Foundation - -// MARK: - Sync Adapter Protocol - -/// Product apps implement this protocol to define their sync endpoints and DTO types. -public protocol BLSyncAdapter { - associatedtype SyncItem: Codable - - /// Pull remote changes since the given date. Return empty array if no changes. - func pullDelta(since: Date?, client: BLPlatformClient) async throws -> [SyncItem] - - /// Push a batch of offline queue items. Return IDs of successfully synced items. - func pushBatch(_ items: [BLOfflineQueueItem], client: BLPlatformClient) async throws -> BLBatchResult -} - -// MARK: - Offline Queue Item - -/// A queued change waiting to be synced. -public struct BLOfflineQueueItem: Codable { - public let id: String - public let action: BLSyncAction - public let payload: Data? // JSON-encoded product-specific DTO - public let enqueuedAt: Date - - public init(id: String, action: BLSyncAction, payload: Data?, enqueuedAt: Date = Date()) { - self.id = id - self.action = action - self.payload = payload - self.enqueuedAt = enqueuedAt - } -} - -public enum BLSyncAction: String, Codable { - case create - case update - case delete -} - -// MARK: - Sync Results - -public struct BLSyncResult { - public let pulled: [T] - public let syncedIds: [String] - public let conflicts: [BLSyncConflict] - public let error: String? - - public init(pulled: [T], syncedIds: [String] = [], conflicts: [BLSyncConflict] = [], error: String? = nil) { - self.pulled = pulled - self.syncedIds = syncedIds - self.conflicts = conflicts - self.error = error - } -} - -public struct BLSyncConflict: Codable { - public let id: String - public let serverVersion: Int - - public init(id: String, serverVersion: Int) { - self.id = id - self.serverVersion = serverVersion - } -} - -public struct BLBatchResult: Codable { - public let synced: [String] - public let conflicts: [BLSyncConflict] - public let errors: [String] - - public init(synced: [String] = [], conflicts: [BLSyncConflict] = [], errors: [String] = []) { - self.synced = synced - self.conflicts = conflicts - self.errors = errors - } -} - -// MARK: - Sync Engine - -/// Generic sync engine. Product apps create one instance with their SyncAdapter. -public final class BLSyncEngine { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - private let adapter: Adapter - - private let storagePrefix: String - private var syncTask: Task? - private let syncIntervalSec: TimeInterval - - // MARK: - State - - public private(set) var isSyncing = false - public private(set) var lastSyncDate: Date? - public private(set) var pendingChanges: Int = 0 - public private(set) var lastError: String? - - public var syncEnabled: Bool { - didSet { - UserDefaults.standard.set(syncEnabled, forKey: "\(storagePrefix)-sync-enabled") - if syncEnabled { schedulePeriodicSync() } else { cancelPeriodicSync() } - } - } - - /// Called when sync state changes (for UI binding). - public var onStateChanged: (() -> Void)? - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - adapter: Adapter, - syncIntervalSec: TimeInterval = 60 - ) { - self.config = config - self.client = client - self.adapter = adapter - self.syncIntervalSec = syncIntervalSec - self.storagePrefix = config.productId - - syncEnabled = UserDefaults.standard.bool(forKey: "\(storagePrefix)-sync-enabled") - - if let date = UserDefaults.standard.object(forKey: "\(storagePrefix)-last-sync") as? Date { - lastSyncDate = date - } - - pendingChanges = loadQueueItems().count - - if syncEnabled { - schedulePeriodicSync() - } - } - - // MARK: - Sync - - /// Full delta sync: pull remote changes, push offline queue. - public func sync() async -> BLSyncResult { - guard syncEnabled, client.authToken != nil else { - return BLSyncResult(pulled: [], error: "Not authenticated or sync disabled") - } - - isSyncing = true - lastError = nil - onStateChanged?() - defer { - isSyncing = false - onStateChanged?() - } - - do { - let pulled = try await adapter.pullDelta(since: lastSyncDate, client: client) - let batchResult = try await pushOfflineQueue() - - lastSyncDate = Date() - UserDefaults.standard.set(lastSyncDate, forKey: "\(storagePrefix)-last-sync") - - return BLSyncResult( - pulled: pulled, - syncedIds: batchResult.synced, - conflicts: batchResult.conflicts, - error: nil - ) - } catch { - lastError = error.localizedDescription - return BLSyncResult(pulled: [], error: error.localizedDescription) - } - } - - // MARK: - Offline Queue - - /// Enqueue a change for later sync. - public func enqueueChange(id: String, action: BLSyncAction, payload: Data?) { - var queue = loadQueueItems() - queue.removeAll { $0.id == id } - queue.append(BLOfflineQueueItem(id: id, action: action, payload: payload)) - saveQueue(queue) - pendingChanges = queue.count - onStateChanged?() - } - - /// Enqueue a delete. - public func enqueueDelete(id: String) { - enqueueChange(id: id, action: .delete, payload: nil) - } - - // MARK: - Private - - private func pushOfflineQueue() async throws -> BLBatchResult { - let queue = loadQueueItems() - guard !queue.isEmpty else { - return BLBatchResult() - } - - let result = try await adapter.pushBatch(queue, client: client) - - // Clear synced items - var remaining = loadQueueItems() - remaining.removeAll { result.synced.contains($0.id) } - saveQueue(remaining) - pendingChanges = remaining.count - - return result - } - - private func schedulePeriodicSync() { - cancelPeriodicSync() - syncTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64((self?.syncIntervalSec ?? 60) * 1_000_000_000)) - guard let self, self.syncEnabled else { break } - _ = await self.sync() - } - } - } - - private func cancelPeriodicSync() { - syncTask?.cancel() - syncTask = nil - } - - // MARK: - Queue Persistence - - private func loadQueueItems() -> [BLOfflineQueueItem] { - guard let data = UserDefaults.standard.data(forKey: "\(storagePrefix)-offline-queue") else { return [] } - return (try? JSONDecoder().decode([BLOfflineQueueItem].self, from: data)) ?? [] - } - - private func saveQueue(_ queue: [BLOfflineQueueItem]) { - if let data = try? JSONEncoder().encode(queue) { - UserDefaults.standard.set(data, forKey: "\(storagePrefix)-offline-queue") - } - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift b/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift deleted file mode 100644 index 1d5350c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/BLTelemetryClient.swift +++ /dev/null @@ -1,214 +0,0 @@ -// ── Telemetry Client ───────────────────────────────────────── -// Generic telemetry event queue + batch flush to platform-service. -// Matches the @bytelyst/telemetry-client TypeScript package interface. -// Product apps configure with BLPlatformConfig; no hardcoded product IDs. - -import Foundation - -/// Telemetry event matching the platform-service /api/telemetry/events schema. -public struct BLTelemetryEvent: Codable, Sendable { - public let id: String - public let productId: String - public let anonymousInstallId: String - public let sessionId: String - public let platform: String - public let channel: String - public let osFamily: String - public let osVersion: String - public let appVersion: String - public let buildNumber: String - public let releaseChannel: String - public let eventType: String - public let module: String - public let eventName: String - public var feature: String? - public var message: String? - public var tags: [String: String]? - public var metrics: [String: Double]? - public let occurredAt: String -} - -/// Generic telemetry client. Queues events in memory and flushes periodically. -/// Thread-safe via NSLock. Fire-and-forget — errors never surface to the user. -public final class BLTelemetryClient { - - private let config: BLPlatformConfig - private let client: BLPlatformClient - - private var queue: [[String: Any]] = [] - private let queueLock = NSLock() - private var flushTimer: Timer? - - private let maxQueue: Int - private let batchSize: Int - private let flushIntervalSec: TimeInterval - - private let installId: String - private var sessionId: String - - private let appVersion: String - private let buildNumber: String - private let releaseChannel: String - private let osVersion: String - - /// Optional extra fields added to every event (e.g. deviceModel, locale, timezone). - public var extraFields: [String: String] = [:] - - public init( - config: BLPlatformConfig, - client: BLPlatformClient, - maxQueue: Int = 200, - batchSize: Int = 50, - flushIntervalSec: TimeInterval = 30, - releaseChannel: String = "beta" - ) { - self.config = config - self.client = client - self.maxQueue = maxQueue - self.batchSize = batchSize - self.flushIntervalSec = flushIntervalSec - self.releaseChannel = releaseChannel - - let bundle = Bundle.main - appVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "0" - osVersion = ProcessInfo.processInfo.operatingSystemVersionString - - // Install ID — persisted in UserDefaults (or App Group if configured) - let storageKey = "\(config.productId)-telemetry-install-id" - let defaults: UserDefaults - if let groupId = config.appGroupId, let groupDefaults = UserDefaults(suiteName: groupId) { - defaults = groupDefaults - } else { - defaults = .standard - } - if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { - installId = existing - } else { - let newId = UUID().uuidString - defaults.set(newId, forKey: storageKey) - installId = newId - } - - sessionId = UUID().uuidString - } - - // MARK: - Lifecycle - - /// Start the periodic flush timer. Call on app launch / foreground. - public func start() { - sessionId = UUID().uuidString - guard flushTimer == nil else { return } - flushTimer = Timer.scheduledTimer(withTimeInterval: flushIntervalSec, repeats: true) { [weak self] _ in - self?.flush() - } - } - - /// Stop the flush timer and flush remaining events. Call on app background. - public func stop() { - flush() - flushTimer?.invalidate() - flushTimer = nil - } - - // MARK: - Track - - /// Track a telemetry event. Thread-safe. - public func trackEvent( - _ eventType: String, - module: String, - name: String, - feature: String? = nil, - message: String? = nil, - tags: [String: String]? = nil, - metrics: [String: Double]? = nil, - userId: String? = nil - ) { - var event: [String: Any] = [ - "id": UUID().uuidString, - "productId": config.productId, - "anonymousInstallId": installId, - "sessionId": sessionId, - "platform": config.platform, - "channel": config.channel, - "osFamily": "ios", - "osVersion": osVersion, - "appVersion": appVersion, - "buildNumber": buildNumber, - "releaseChannel": releaseChannel, - "eventType": eventType, - "module": module, - "eventName": name, - "occurredAt": ISO8601DateFormatter().string(from: Date()), - ] - - if let feature { event["feature"] = feature } - if let message { event["message"] = String(message.prefix(512)) } - if let tags { event["tags"] = tags } - if let metrics { event["metrics"] = metrics } - if let userId { event["userId"] = userId } - - for (key, value) in extraFields { - event[key] = value - } - - enqueue(event) - } - - /// Convenience: track a screen view. - public func trackScreen(_ screen: String) { - trackEvent("info", module: "navigation", name: "screen_view", tags: ["screen": screen]) - } - - // MARK: - Flush - - /// Flush all queued events to the server. Thread-safe. - public func flush() { - queueLock.lock() - let events = queue - queue.removeAll() - queueLock.unlock() - - guard !events.isEmpty else { return } - - // Batch into chunks - let chunks = stride(from: 0, to: events.count, by: batchSize).map { - Array(events[$0.. String { installId } - public func getSessionId() -> String { sessionId } - - // MARK: - Private - - private func enqueue(_ event: [String: Any]) { - queueLock.lock() - queue.append(event) - if queue.count > maxQueue { - queue.removeFirst(queue.count - maxQueue) - } - let count = queue.count - queueLock.unlock() - - if count >= batchSize { - flush() - } - } - - private func sendBatch(_ events: [[String: Any]]) { - let body: [String: Any] = [ - "productId": config.productId, - "events": events, - ] - - guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return } - client.fireAndForget(path: "/api/telemetry/events", body: jsonData) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift deleted file mode 100644 index 1e92073..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatform.swift +++ /dev/null @@ -1,122 +0,0 @@ -// ── ByteLystPlatform ──────────────────────────────────────── -// Unified entry point for the ByteLyst platform SDK. -// Creates and wires all platform services from a single config. -// -// Usage: -// let platform = ByteLystPlatform(config: .init( -// productId: "peakpulse", -// baseURL: "https://api.peakpulse.app", -// bundleId: "com.saravana.peakpulse" -// )) -// -// platform.start() // Start telemetry + flags + kill switch -// platform.telemetry.trackScreen("home") // Track events -// let isNew = platform.flags.isEnabled("new_feature") -// platform.stop() // Flush + stop timers - -import Foundation - -/// Unified entry point that wires all ByteLyst platform services together. -/// Create one instance at app launch and access services via properties. -public final class ByteLystPlatform { - - /// Platform configuration. - public let config: BLPlatformConfig - - /// HTTP client shared by all services. - public let client: BLPlatformClient - - /// Telemetry event tracking. - public let telemetry: BLTelemetryClient - - /// Feature flag polling. - public let flags: BLFeatureFlagClient - - /// Kill switch checker. - public let killSwitch: BLKillSwitchClient - - /// Crash reporter (MetricKit). Created lazily on MainActor. - public private(set) var crashReporter: BLCrashReporter? - - /// Keychain access (via bundleId as service). - public let keychain: BLKeychainAccessor - - /// Audit logger type (static API — call BLAuditLogger.log()). - public let auditLog: BLAuditLogger.Type = BLAuditLogger.self - - /// Auth client. - public let auth: BLAuthClient - - /// Whether `start()` has been called. - public private(set) var isStarted = false - - public init(config: BLPlatformConfig) { - self.config = config - self.client = BLPlatformClient(config: config) - self.telemetry = BLTelemetryClient(config: config, client: client) - self.flags = BLFeatureFlagClient(config: config, client: client) - self.killSwitch = BLKillSwitchClient(config: config) - self.keychain = BLKeychainAccessor(service: config.bundleId) - self.auth = BLAuthClient(config: config, client: client) - BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") - } - - /// Test-only initializer that accepts a custom URLSessionConfiguration. - public init(config: BLPlatformConfig, sessionConfiguration: URLSessionConfiguration) { - self.config = config - self.client = BLPlatformClient(config: config, sessionConfiguration: sessionConfiguration) - self.telemetry = BLTelemetryClient(config: config, client: client) - self.flags = BLFeatureFlagClient(config: config, client: client) - self.killSwitch = BLKillSwitchClient(config: config) - self.keychain = BLKeychainAccessor(service: config.bundleId) - self.auth = BLAuthClient(config: config, client: client) - BLAuditLogger.configure(fileName: "\(config.productId)_audit_log.json") - } - - // MARK: - Lifecycle - - /// Start all services: telemetry flush timer, feature flag polling, kill switch check. - public func start(userId: String? = nil) { - guard !isStarted else { return } - isStarted = true - telemetry.start() - flags.start(userId: userId) - Task { await killSwitch.check() } - Task { @MainActor in - self.crashReporter = BLCrashReporter(productId: config.productId) - } - } - - /// Stop all services: flush telemetry, stop flag polling. - public func stop() { - guard isStarted else { return } - isStarted = false - telemetry.stop() - flags.stop() - } -} - -// MARK: - Keychain Accessor - -/// Convenience wrapper around BLKeychain that binds to a specific service (bundleId). -public struct BLKeychainAccessor { - private let service: String - - public init(service: String) { - self.service = service - } - - @discardableResult - public func save(key: String, value: String) -> Bool { - BLKeychain.save(service: service, key: key, value: value) - } - - public func read(key: String) -> String? { - BLKeychain.read(service: service, key: key) - } - - @discardableResult - public func delete(key: String) -> Bool { - BLKeychain.delete(service: service, key: key) - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift b/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift deleted file mode 100644 index 8da8a2a..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Sources/ByteLystPlatformSDK.swift +++ /dev/null @@ -1,34 +0,0 @@ -// ── ByteLystPlatformSDK ────────────────────────────────────── -// Shared Swift platform client for all ByteLyst iOS/watchOS/macOS apps. -// Re-exports all public types via their respective source files. -// -// Usage in product apps: -// import ByteLystPlatformSDK -// -// let config = BLPlatformConfig(productId: "peakpulse", baseURL: "...", bundleId: "com.saravana.peakpulse") -// let client = BLPlatformClient(config: config) -// let telemetry = BLTelemetryClient(config: config, client: client) -// let auth = BLAuthClient(config: config, client: client) -// let flags = BLFeatureFlagClient(config: config, client: client) -// let blob = BLBlobClient(config: config, client: client) -// let license = BLLicenseClient(config: config, client: client) -// let killSwitch = BLKillSwitchClient(config: config) -// let crashReporter = BLCrashReporter(productId: config.productId) -// -// Components (13 source files): -// - BLPlatformConfig — Product-specific config (productId, baseURL, bundleId, appGroupId) -// - BLPlatformClient — Generic HTTP client (auth injection, x-request-id, fire-and-forget) -// - BLKeychain — Keychain CRUD (configurable service string) -// - BLTelemetryClient — Telemetry event queue + batch flush -// - BLAuthClient — Full auth operations (login, register, refresh, password ops) -// - BLFeatureFlagClient — Feature flag polling from /api/flags/poll -// - BLSyncEngine — Generic offline-first sync with BLSyncAdapter protocol -// - BLBlobClient — Azure Blob Storage upload via SAS tokens -// - BLKillSwitchClient — Kill switch check from platform-service -// - BLLicenseClient — License key activation via platform-service -// - BLBiometricAuth — Face ID / Touch ID wrapper -// - BLCrashReporter — MetricKit crash and performance reporting -// - BLAuditLogger — Local rotating JSON audit log - -// All types are exported via their respective files. -// This file exists for module-level documentation only. diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift deleted file mode 100644 index c9ef73c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLAuthClientSmartAuthTests.swift +++ /dev/null @@ -1,196 +0,0 @@ -// ── BLAuthClient SmartAuth v2 Tests ───────────────────────── -// Tests for social login, MFA verify methods. -// Uses URLProtocol mocking to intercept network requests. - -import XCTest -@testable import ByteLystPlatformSDK - -// MARK: - Mock URL Protocol - -private class MockURLProtocol: URLProtocol { - static var mockResponses: [String: (Data, Int)] = [:] - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - let path = request.url?.path ?? "" - let method = request.httpMethod ?? "GET" - let key = "\(method) \(path)" - - if let (data, statusCode) = MockURLProtocol.mockResponses[key] { - let response = HTTPURLResponse( - url: request.url!, - statusCode: statusCode, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - } else { - let response = HTTPURLResponse( - url: request.url!, - statusCode: 404, - httpVersion: nil, - headerFields: nil - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data()) - } - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} - -// MARK: - Tests - -final class BLAuthClientSmartAuthTests: XCTestCase { - - private var config: BLPlatformConfig! - private var client: BLPlatformClient! - private var authClient: BLAuthClient! - - override func setUp() { - super.setUp() - config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - platform: "ios", - channel: "test", - bundleId: "com.test.smartauth" - ) - - // Configure URLSession with mock protocol wired into BLPlatformClient - let sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [MockURLProtocol.self] - - client = BLPlatformClient(config: config, sessionConfiguration: sessionConfig) - authClient = BLAuthClient(config: config, client: client) - - MockURLProtocol.mockResponses.removeAll() - } - - override func tearDown() { - MockURLProtocol.mockResponses.removeAll() - // Clean up keychain - BLKeychain.delete(service: "com.test.smartauth", key: "access_token") - BLKeychain.delete(service: "com.test.smartauth", key: "refresh_token") - super.tearDown() - } - - // MARK: - Test 1: Swift Social Login (Google) - - func testLoginWithGoogleCallsCorrectEndpoint() async throws { - // Mock a successful token response for Google OAuth - let tokenJSON = """ - {"accessToken":"at_123","refreshToken":"rt_456","user":{"id":"u1","email":"test@google.com","displayName":"Test User","plan":"free","role":"user"}} - """ - MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (tokenJSON.data(using: .utf8)!, 200) - - let user = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") - XCTAssertEqual(user.id, "u1") - XCTAssertEqual(user.email, "test@google.com") - XCTAssertEqual(user.displayName, "Test User") - } - - func testLoginWithGoogleDetectsMfaChallenge() async { - // Mock an MFA challenge response for Google OAuth - let mfaJSON = """ - {"mfaRequired":true,"mfaChallenge":"ch_google_123","methods":["totp"]} - """ - MockURLProtocol.mockResponses["POST /api/auth/oauth/google"] = (mfaJSON.data(using: .utf8)!, 200) - - do { - _ = try await authClient.loginWithGoogle(idToken: "mock_google_id_token") - XCTFail("Should have thrown BLAuthError.mfaRequired") - } catch let error as BLAuthError { - if case .mfaRequired(let challenge) = error { - XCTAssertEqual(challenge.mfaChallenge, "ch_google_123") - XCTAssertEqual(challenge.methods, ["totp"]) - } else { - XCTFail("Expected mfaRequired error") - } - } - } - - // MARK: - Test 2: Swift MFA Verify - - func testVerifyMfaCallsCorrectEndpoint() async throws { - // Mock a successful MFA verification response - let tokenJSON = """ - {"accessToken":"at_mfa","refreshToken":"rt_mfa","user":{"id":"u2","email":"mfa@test.com","displayName":"MFA User"}} - """ - MockURLProtocol.mockResponses["POST /api/auth/mfa/verify"] = (tokenJSON.data(using: .utf8)!, 200) - - let user = try await authClient.verifyMfa( - challengeToken: "mock_challenge_token", - code: "123456", - method: "totp" - ) - XCTAssertEqual(user.id, "u2") - XCTAssertEqual(user.email, "mfa@test.com") - } - - // MARK: - Type Verification Tests - - func testSmartAuthTypesAreDecodable() throws { - // BLMfaChallenge - let challengeJSON = """ - {"mfaRequired":true,"mfaChallenge":"ch_abc123","methods":["totp"]} - """ - let challenge = try JSONDecoder().decode(BLMfaChallenge.self, from: challengeJSON.data(using: .utf8)!) - XCTAssertTrue(challenge.mfaRequired) - XCTAssertEqual(challenge.mfaChallenge, "ch_abc123") - XCTAssertEqual(challenge.methods, ["totp"]) - - // BLMfaStatus - let statusJSON = """ - {"mfaEnabled":true,"methods":["totp"],"recoveryCodesRemaining":6} - """ - let status = try JSONDecoder().decode(BLMfaStatus.self, from: statusJSON.data(using: .utf8)!) - XCTAssertTrue(status.mfaEnabled) - XCTAssertEqual(status.recoveryCodesRemaining, 6) - - // BLAuthProvider - let providerJSON = """ - {"provider":"google","email":"test@test.com","linkedAt":"2026-01-01T00:00:00Z","lastUsedAt":null} - """ - let provider = try JSONDecoder().decode(BLAuthProvider.self, from: providerJSON.data(using: .utf8)!) - XCTAssertEqual(provider.provider, "google") - XCTAssertNil(provider.lastUsedAt) - - // BLDevice - let deviceJSON = """ - {"id":"dev_1","name":"iPhone 16","platform":"ios","trustLevel":"trusted","trustExpiresAt":"2026-06-01T00:00:00Z","lastLoginAt":"2026-03-01T00:00:00Z"} - """ - let device = try JSONDecoder().decode(BLDevice.self, from: deviceJSON.data(using: .utf8)!) - XCTAssertEqual(device.trustLevel, "trusted") - XCTAssertEqual(device.platform, "ios") - - // BLLoginEvent - let eventJSON = """ - {"id":"evt_1","eventType":"login_success","method":"google","ip":"1.2.3.4","geo":{"country":"US","city":"SF"},"riskScore":15,"createdAt":"2026-03-01T00:00:00Z"} - """ - let event = try JSONDecoder().decode(BLLoginEvent.self, from: eventJSON.data(using: .utf8)!) - XCTAssertEqual(event.riskScore, 15) - XCTAssertEqual(event.geo?.city, "SF") - } - - func testAuthStateIncludesMfaRequired() { - let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp"]) - let state = BLAuthState.mfaRequired(challenge) - - if case .mfaRequired(let c) = state { - XCTAssertEqual(c.mfaChallenge, "ch_test") - } else { - XCTFail("Expected mfaRequired state") - } - } - - func testBLAuthErrorMfaRequired() { - let challenge = BLMfaChallenge(mfaRequired: true, mfaChallenge: "ch_test", methods: ["totp", "recovery"]) - let error = BLAuthError.mfaRequired(challenge) - XCTAssertEqual(error.localizedDescription, "Multi-factor authentication required") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift deleted file mode 100644 index 4f63401..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLFeatureFlagClientTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLFeatureFlagClientTests: XCTestCase { - - private func makeClient() -> BLFeatureFlagClient { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - let platformClient = BLPlatformClient(config: config) - return BLFeatureFlagClient(config: config, client: platformClient) - } - - func testDefaultFlagsEmpty() { - let client = makeClient() - XCTAssertEqual(client.allFlags().count, 0) - } - - func testIsEnabledReturnsFalseForUnknownKey() { - let client = makeClient() - XCTAssertFalse(client.isEnabled("nonexistent_flag")) - } - - func testStopDoesNotCrash() { - let client = makeClient() - client.stop() - client.stop() // Double stop - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift deleted file mode 100644 index 5180c17..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLFieldEncryptTests.swift +++ /dev/null @@ -1,186 +0,0 @@ -import XCTest -import CryptoKit -@testable import ByteLystPlatformSDK - -final class BLFieldEncryptTests: XCTestCase { - - private var key: SymmetricKey! - private let dekId = "dek_user1_test" - - override func setUp() { - super.setUp() - key = BLFieldEncrypt.generateKey() - } - - // MARK: - Encrypt / Decrypt Roundtrip - - func testEncryptDecryptRoundtrip() throws { - let plaintext = "Hello, World!" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptEmptyString() throws { - let plaintext = "" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptUnicode() throws { - let plaintext = "こんにちは世界 🌍 مرحبا Ñoño" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - func testEncryptDecryptLargePayload() throws { - let plaintext = String(repeating: "A", count: 100_000) - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, plaintext) - } - - // MARK: - EncryptedField Structure - - func testEncryptedFieldHasCorrectSentinel() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - XCTAssertTrue(encrypted.__encrypted) - XCTAssertEqual(encrypted.v, 1) - XCTAssertEqual(encrypted.alg, "aes-256-gcm") - XCTAssertEqual(encrypted.dekId, dekId) - } - - func testEncryptedFieldHasCorrectHexLengths() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - // IV: 12 bytes = 24 hex chars - XCTAssertEqual(encrypted.iv.count, 24) - // Tag: 16 bytes = 32 hex chars - XCTAssertEqual(encrypted.tag.count, 32) - // ct should be non-empty hex - XCTAssertFalse(encrypted.ct.isEmpty) - } - - func testEachEncryptionProducesUniqueIV() throws { - let a = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) - let b = try BLFieldEncrypt.encrypt("same", key: key, dekId: dekId) - XCTAssertNotEqual(a.iv, b.iv, "Each encryption should use a unique IV") - XCTAssertNotEqual(a.ct, b.ct, "Ciphertext should differ with different IVs") - } - - // MARK: - AAD (Additional Authenticated Data) - - func testEncryptDecryptWithAAD() throws { - let plaintext = "secret data" - let aad = "user123:notes" - let encrypted = try BLFieldEncrypt.encrypt(plaintext, key: key, dekId: dekId, aad: aad) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key, aad: aad) - XCTAssertEqual(decrypted, plaintext) - } - - func testDecryptWithWrongAADFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "correct") - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key, aad: "wrong")) - } - - func testDecryptWithMissingAADFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId, aad: "some-aad") - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: key)) - } - - // MARK: - Wrong Key - - func testDecryptWithWrongKeyFails() throws { - let encrypted = try BLFieldEncrypt.encrypt("secret", key: key, dekId: dekId) - let wrongKey = BLFieldEncrypt.generateKey() - XCTAssertThrowsError(try BLFieldEncrypt.decrypt(encrypted, key: wrongKey)) - } - - // MARK: - Key Size Validation - - func testEncryptRejectsShortKey() { - let shortKey = SymmetricKey(size: .bits128) - XCTAssertThrowsError(try BLFieldEncrypt.encrypt("test", key: shortKey, dekId: dekId)) { error in - guard let encError = error as? BLFieldEncryptError, - case .invalidKeySize = encError else { - XCTFail("Expected invalidKeySize error") - return - } - } - } - - // MARK: - Key from Hex - - func testKeyFromHex() throws { - let hex = String(repeating: "ab", count: 32) // 64 hex chars = 32 bytes - let key = try BLFieldEncrypt.keyFromHex(hex) - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - let decrypted = try BLFieldEncrypt.decrypt(encrypted, key: key) - XCTAssertEqual(decrypted, "test") - } - - func testKeyFromHexRejectsInvalidLength() { - XCTAssertThrowsError(try BLFieldEncrypt.keyFromHex("aabb")) - } - - // MARK: - isEncrypted Type Guard - - func testIsEncryptedWithValidField() throws { - let encrypted = try BLFieldEncrypt.encrypt("test", key: key, dekId: dekId) - XCTAssertTrue(BLFieldEncrypt.isEncrypted(encrypted)) - } - - func testIsEncryptedWithNil() { - XCTAssertFalse(BLFieldEncrypt.isEncrypted(nil as BLEncryptedField?)) - } - - func testIsEncryptedWithDictionary() { - let dict: [String: Any] = [ - "__encrypted": true, - "v": 1, - "alg": "aes-256-gcm", - "ct": "abcd", - "iv": "1234", - "tag": "5678", - "dekId": "dek_test", - ] - XCTAssertTrue(BLFieldEncrypt.isEncrypted(dict)) - } - - func testIsEncryptedWithPlainString() { - XCTAssertFalse(BLFieldEncrypt.isEncrypted("just a string" as Any)) - } - - func testIsEncryptedWithIncompleteDict() { - let dict: [String: Any] = ["__encrypted": true, "v": 1] - XCTAssertFalse(BLFieldEncrypt.isEncrypted(dict)) - } - - // MARK: - JSON Codable Roundtrip - - func testEncryptedFieldCodableRoundtrip() throws { - let encrypted = try BLFieldEncrypt.encrypt("codable test", key: key, dekId: dekId) - let encoder = JSONEncoder() - let data = try encoder.encode(encrypted) - let decoder = JSONDecoder() - let decoded = try decoder.decode(BLEncryptedField.self, from: data) - let decrypted = try BLFieldEncrypt.decrypt(decoded, key: key) - XCTAssertEqual(decrypted, "codable test") - } - - // MARK: - Hex Helpers - - func testDataHexRoundtrip() { - let original = Data([0x00, 0x0f, 0xff, 0xab, 0xcd]) - let hex = original.hexString - XCTAssertEqual(hex, "000fffabcd") - let restored = Data(hexString: hex) - XCTAssertEqual(restored, original) - } - - func testDataHexInvalidString() { - XCTAssertNil(Data(hexString: "xyz")) - XCTAssertNil(Data(hexString: "a")) // odd length - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift deleted file mode 100644 index 046f09a..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLKeychainTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLKeychainTests: XCTestCase { - - private let service = "com.bytelyst.test" - - override func tearDown() { - BLKeychain.delete(service: service, key: "test_key") - super.tearDown() - } - - func testSaveAndRead() { - BLKeychain.save(service: service, key: "test_key", value: "hello") - XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "hello") - } - - func testReadNonExistent() { - XCTAssertNil(BLKeychain.read(service: service, key: "nonexistent")) - } - - func testDelete() { - BLKeychain.save(service: service, key: "test_key", value: "hello") - BLKeychain.delete(service: service, key: "test_key") - XCTAssertNil(BLKeychain.read(service: service, key: "test_key")) - } - - func testOverwrite() { - BLKeychain.save(service: service, key: "test_key", value: "first") - BLKeychain.save(service: service, key: "test_key", value: "second") - XCTAssertEqual(BLKeychain.read(service: service, key: "test_key"), "second") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift deleted file mode 100644 index e018b0c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLKillSwitchClientTests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLKillSwitchClientTests: XCTestCase { - - private func makeConfig() -> BLPlatformConfig { - BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - } - - func testDefaultState() { - let client = BLKillSwitchClient(config: makeConfig()) - XCTAssertFalse(client.isDisabled) - XCTAssertEqual(client.maintenanceMessage, "") - } - - func testReset() { - let client = BLKillSwitchClient(config: makeConfig()) - // Manually test reset clears any hypothetical state - client.reset() - XCTAssertFalse(client.isDisabled) - XCTAssertEqual(client.maintenanceMessage, "") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift deleted file mode 100644 index f199d7c..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLPlatformConfigTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLPlatformConfigTests: XCTestCase { - - func testInitWithDefaults() { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.testapp" - ) - XCTAssertEqual(config.productId, "testapp") - XCTAssertEqual(config.baseURL, "http://localhost:4003") - XCTAssertEqual(config.platform, "ios") - XCTAssertEqual(config.channel, "native") - XCTAssertEqual(config.bundleId, "com.bytelyst.testapp") - XCTAssertNil(config.appGroupId) - } - - func testInitWithCustomValues() { - let config = BLPlatformConfig( - productId: "peakpulse", - baseURL: "https://api.peakpulse.app", - platform: "watchos", - channel: "companion", - bundleId: "com.saravana.peakpulse", - appGroupId: "group.com.saravana.peakpulse" - ) - XCTAssertEqual(config.productId, "peakpulse") - XCTAssertEqual(config.platform, "watchos") - XCTAssertEqual(config.channel, "companion") - XCTAssertEqual(config.appGroupId, "group.com.saravana.peakpulse") - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift deleted file mode 100644 index dc2d534..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/BLTelemetryClientTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class BLTelemetryClientTests: XCTestCase { - - private func makeClient() -> BLTelemetryClient { - let config = BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003", - bundleId: "com.bytelyst.test" - ) - let platformClient = BLPlatformClient(config: config) - return BLTelemetryClient(config: config, client: platformClient) - } - - func testInstallIdIsStable() { - let client = makeClient() - let id1 = client.getInstallId() - let id2 = client.getInstallId() - XCTAssertEqual(id1, id2) - XCTAssertFalse(id1.isEmpty) - } - - func testSessionIdIsNotEmpty() { - let client = makeClient() - XCTAssertFalse(client.getSessionId().isEmpty) - } - - func testStartGeneratesNewSessionId() { - let client = makeClient() - let sessionBefore = client.getSessionId() - client.start() - let sessionAfter = client.getSessionId() - // start() rotates session ID - XCTAssertNotEqual(sessionBefore, sessionAfter) - client.stop() - } - - func testStopDoesNotCrash() { - let client = makeClient() - client.stop() // Stop without start - client.start() - client.stop() - client.stop() // Double stop - } - - func testTrackEventDoesNotCrash() { - let client = makeClient() - client.trackEvent("info", module: "test", name: "unit_test") - client.trackEvent("error", module: "test", name: "fail", message: "test error") - client.trackEvent("info", module: "test", name: "tagged", tags: ["key": "value"]) - client.trackEvent("info", module: "test", name: "measured", metrics: ["duration": 1.5]) - } - - func testTrackScreenDoesNotCrash() { - let client = makeClient() - client.trackScreen("home") - client.trackScreen("settings") - } - - func testFlushDoesNotCrashWhenEmpty() { - let client = makeClient() - client.flush() // Empty queue - } -} diff --git a/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift b/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift deleted file mode 100644 index 065c6fd..0000000 --- a/vendor/bytelyst/swift-platform-sdk/Tests/ByteLystPlatformTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import XCTest -@testable import ByteLystPlatformSDK - -final class ByteLystPlatformTests: XCTestCase { - - private func makeConfig() -> BLPlatformConfig { - BLPlatformConfig( - productId: "testapp", - baseURL: "http://localhost:4003/api", - platform: "ios", - channel: "native", - bundleId: "com.bytelyst.test" - ) - } - - func testInitCreatesAllServices() { - let platform = ByteLystPlatform(config: makeConfig()) - XCTAssertEqual(platform.config.productId, "testapp") - XCTAssertNotNil(platform.client) - XCTAssertNotNil(platform.telemetry) - XCTAssertNotNil(platform.flags) - XCTAssertNotNil(platform.killSwitch) - XCTAssertNotNil(platform.crashReporter) - XCTAssertNotNil(platform.keychain) - XCTAssertNotNil(platform.auditLog) - XCTAssertNotNil(platform.auth) - } - - func testStartSetsIsStarted() { - let platform = ByteLystPlatform(config: makeConfig()) - XCTAssertFalse(platform.isStarted) - platform.start() - XCTAssertTrue(platform.isStarted) - } - - func testStopClearsIsStarted() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - XCTAssertTrue(platform.isStarted) - platform.stop() - XCTAssertFalse(platform.isStarted) - } - - func testDoubleStartIsIdempotent() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - platform.start() // Should not crash or double-start - XCTAssertTrue(platform.isStarted) - platform.stop() - } - - func testDoubleStopIsIdempotent() { - let platform = ByteLystPlatform(config: makeConfig()) - platform.start() - platform.stop() - platform.stop() // Should not crash - XCTAssertFalse(platform.isStarted) - } - - func testKeychainAccessor() { - let accessor = BLKeychainAccessor(service: "com.bytelyst.test.accessor") - accessor.save(key: "test_key", value: "hello") - XCTAssertEqual(accessor.read(key: "test_key"), "hello") - accessor.delete(key: "test_key") - XCTAssertNil(accessor.read(key: "test_key")) - } -} diff --git a/vendor/bytelyst/sync/package.json b/vendor/bytelyst/sync/package.json deleted file mode 100644 index a205f63..0000000 --- a/vendor/bytelyst/sync/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@bytelyst/sync", - "version": "0.1.5", - "description": "Offline-first sync engine with configurable storage adapters and conflict resolution", - "type": "module", - "main": "./dist/index.js", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks", - "test:watch": "vitest", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@bytelyst/api-client": "workspace:*", - "@bytelyst/telemetry-client": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.12.0", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "@bytelyst/api-client": "workspace:*" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/sync/src/engine.ts b/vendor/bytelyst/sync/src/engine.ts deleted file mode 100644 index 277e2e0..0000000 --- a/vendor/bytelyst/sync/src/engine.ts +++ /dev/null @@ -1,603 +0,0 @@ -/** - * Sync Engine — Core implementation - * - * Offline-first sync with: - * - Queue persistence via pluggable StorageAdapter - * - Deduplication (collapse updates to same entity + id) - * - Exponential backoff retry (configurable base/max delay) - * - Conflict detection via HTTP 409 + configurable resolution strategies - * - Connectivity detection with auto-flush on reconnect - * - Telemetry integration for sync success/failure/conflict tracking - * - onPull callback so consumers merge pulled data into local store - * - * @module @bytelyst/sync/engine - */ - -import type { - SyncEngine, - SyncEngineConfig, - SyncItem, - SyncResult, - SyncStatus, - SyncStatusInfo, - SyncStatusCallback, - EntityName, - SyncOperation, - ConflictStrategy, - Conflict, -} from './types.js'; - -// ───────────────────────────────────────────────────────────────────────────── -// Constants -// ───────────────────────────────────────────────────────────────────────────── - -const DEFAULT_MAX_RETRIES = 5; -const DEFAULT_RETRY_BASE_DELAY_MS = 1000; -const DEFAULT_RETRY_MAX_DELAY_MS = 30_000; -const QUEUE_KEY = 'queue'; -const LAST_SYNC_KEY = 'lastSync'; - -// ───────────────────────────────────────────────────────────────────────────── -// HTTP 409 Conflict Error -// ───────────────────────────────────────────────────────────────────────────── - -export class SyncConflictError extends Error { - constructor(public remoteData: unknown) { - super('Sync conflict: server has newer version'); - this.name = 'SyncConflictError'; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** Compute exponential backoff with jitter: base * 2^attempt + random jitter */ -export function computeBackoff(attempt: number, baseMs: number, maxMs: number): number { - const delay = Math.min(baseMs * Math.pow(2, attempt), maxMs); - const jitter = delay * 0.1 * Math.random(); - return delay + jitter; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine Implementation -// ───────────────────────────────────────────────────────────────────────────── - -export class SyncEngineImpl implements SyncEngine { - private config: Required< - Pick - > & - SyncEngineConfig; - private status: SyncStatus = 'idle'; - private queueLength = 0; - private lastSyncAt?: string; - private lastError?: string; - private statusListeners: Set = new Set(); - private onlineHandler: (() => void) | null = null; - private offlineHandler: (() => void) | null = null; - private destroyed = false; - - constructor(config: SyncEngineConfig) { - this.config = { - maxRetries: DEFAULT_MAX_RETRIES, - retryBaseDelayMs: DEFAULT_RETRY_BASE_DELAY_MS, - retryMaxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS, - ...config, - }; - this.setupConnectivityDetection(); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Core Operations - // ─────────────────────────────────────────────────────────────────────────── - - async push( - entity: EntityName, - data: unknown, - operation: SyncOperation = 'create' - ): Promise { - const item: SyncItem = { - id: this.generateId(), - entity, - operation, - data, - timestamp: new Date().toISOString(), - retryCount: 0, - }; - - const existingQueue = await this.getQueue(); - const dedupKey = this.getDedupKey(entity, data); - const existingIndex = existingQueue.findIndex( - i => this.getDedupKey(i.entity, i.data) === dedupKey && i.operation === operation - ); - - if (existingIndex >= 0) { - existingQueue[existingIndex] = item; - } else { - existingQueue.push(item); - } - - await this.saveQueue(existingQueue); - this.notifyStatus(); - } - - async delete(entity: EntityName, id: string): Promise { - // Also remove any pending create/update for this entity+id - const queue = await this.getQueue(); - const dedupKey = `${entity}:${id}`; - const filtered = queue.filter(i => this.getDedupKey(i.entity, i.data) !== dedupKey); - - const item: SyncItem = { - id: this.generateId(), - entity, - operation: 'delete', - data: { id }, - timestamp: new Date().toISOString(), - retryCount: 0, - }; - filtered.push(item); - - await this.saveQueue(filtered); - this.notifyStatus(); - } - - async pull(): Promise { - const result = this.emptyResult(); - this.setStatus('syncing'); - - try { - for (const [entityName, entityConfig] of Object.entries(this.config.entities)) { - try { - const count = await this.pullEntity(entityName, entityConfig.endpoint); - result.pulled += count; - } catch (error) { - result.errors++; - this.trackTelemetry('sync_pull_error', entityName, error); - } - } - - result.timestamp = new Date().toISOString(); - await this.setLastSyncTime(result.timestamp); - this.lastSyncAt = result.timestamp; - this.setStatus(result.errors > 0 ? 'error' : 'idle'); - } catch (error) { - result.success = false; - this.setStatus('error', error instanceof Error ? error.message : 'Unknown error'); - } - - return result; - } - - async fullSync(): Promise { - if (!this.isOnline()) { - this.setStatus('offline'); - return { ...this.emptyResult(), success: false }; - } - - const pushResult = await this.pushQueue(); - const pullResult = await this.pull(); - - const combined: SyncResult = { - success: pushResult.success && pullResult.success, - pushed: pushResult.pushed, - pulled: pullResult.pulled, - conflicts: pushResult.conflicts + pullResult.conflicts, - errors: pushResult.errors + pullResult.errors, - timestamp: new Date().toISOString(), - }; - - this.trackTelemetry('sync_complete', '*', undefined, { - pushed: combined.pushed, - pulled: combined.pulled, - conflicts: combined.conflicts, - errors: combined.errors, - }); - - return combined; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Queue Management - // ─────────────────────────────────────────────────────────────────────────── - - private async getQueue(): Promise { - const queue = await this.config.storage.getItem(QUEUE_KEY); - return queue || []; - } - - private async saveQueue(queue: SyncItem[]): Promise { - await this.config.storage.setItem(QUEUE_KEY, queue); - this.queueLength = queue.length; - } - - private async pushQueue(): Promise { - const queue = await this.getQueue(); - const result = this.emptyResult(); - - if (queue.length === 0) return result; - - this.setStatus('syncing'); - const remaining: SyncItem[] = []; - - for (const item of queue) { - try { - await this.pushItemWithRetry(item); - result.pushed++; - } catch (error) { - if (error instanceof SyncConflictError) { - result.conflicts++; - const resolved = await this.handleConflict(item, error.remoteData); - if (resolved) { - result.pushed++; - } else { - result.errors++; - } - } else if (item.retryCount < this.config.maxRetries) { - item.retryCount++; - item.lastError = error instanceof Error ? error.message : String(error); - remaining.push(item); - } else { - result.errors++; - this.trackTelemetry('sync_push_dropped', item.entity, error); - } - } - } - - await this.saveQueue(remaining); - - if (result.errors > 0) { - result.success = false; - } - - return result; - } - - private async pushItemWithRetry(item: SyncItem): Promise { - const entityConfig = this.config.entities[item.entity]; - if (!entityConfig) { - throw new Error(`Unknown entity: ${item.entity}`); - } - - const dataId = (item.data as { id?: string })?.id; - const path = - (item.operation === 'delete' || item.operation === 'update') && dataId - ? `${entityConfig.endpoint}/${dataId}` - : entityConfig.endpoint; - - const method = - item.operation === 'delete' ? 'DELETE' : item.operation === 'update' ? 'PATCH' : 'POST'; - - const headers: Record = {}; - if (method !== 'DELETE') { - headers['Content-Type'] = 'application/json'; - } - - // Attempt with exponential backoff on transient failures - let lastError: unknown; - const maxAttempts = Math.max(1, this.config.maxRetries - item.retryCount); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - await this.config.apiClient.fetch(path, { - method, - headers, - body: method !== 'DELETE' ? JSON.stringify(item.data) : undefined, - }); - // Success — track and return - this.trackTelemetry('sync_push_success', item.entity); - return; - } catch (error) { - lastError = error; - - // Check for conflict (409) — don't retry, let caller handle - if (error instanceof SyncConflictError) { - throw error; - } - if (this.isConflictError(error)) { - throw new SyncConflictError(undefined); - } - - // Non-retriable errors — throw immediately - if (this.isNonRetriable(error)) { - throw error; - } - - // Transient error — backoff and retry - if (attempt < maxAttempts - 1) { - const delay = computeBackoff( - attempt, - this.config.retryBaseDelayMs, - this.config.retryMaxDelayMs - ); - await sleep(delay); - } - } - } - - throw lastError; - } - - private async pullEntity(entityName: string, endpoint: string): Promise { - const lastSync = await this.getLastSyncTime(); - const path = lastSync ? `${endpoint}?since=${encodeURIComponent(lastSync)}` : endpoint; - - const response = await this.config.apiClient.safeFetch<{ items: unknown[] }>(path); - - if (response.error || !response.data) { - return 0; - } - - const items = response.data.items ?? []; - - if (items.length > 0 && this.config.onPull) { - await this.config.onPull(entityName, items); - } - - return items.length; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Conflict Resolution - // ─────────────────────────────────────────────────────────────────────────── - - private async handleConflict(item: SyncItem, remoteData: unknown): Promise { - const entityConfig = this.config.entities[item.entity]; - if (!entityConfig) return false; - - const strategy = entityConfig.conflictStrategy; - this.trackTelemetry('sync_conflict', item.entity, undefined, { strategy }); - - try { - const winner = await this.resolveConflict(item, remoteData, strategy); - - if (winner === remoteData) { - // Server wins — nothing to push, consumer gets remote via onPull - if (this.config.onPull) { - await this.config.onPull(item.entity, [remoteData]); - } - return true; - } - - // Client data wins — re-push with force - const dataId = (winner as { id?: string })?.id; - const endpoint = entityConfig.endpoint; - const path = dataId ? `${endpoint}/${dataId}` : endpoint; - await this.config.apiClient.fetch(path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(winner), - }); - return true; - } catch { - return false; - } - } - - private async resolveConflict( - item: SyncItem, - remoteData: unknown, - strategy: ConflictStrategy - ): Promise { - switch (strategy) { - case 'server-wins': - return remoteData; - - case 'client-wins': - return item.data; - - case 'last-write-wins': { - const localTime = new Date(item.timestamp).getTime(); - const remoteTime = new Date( - (remoteData as { updatedAt?: string })?.updatedAt ?? '1970-01-01' - ).getTime(); - return localTime > remoteTime ? item.data : remoteData; - } - - case 'manual': { - if (this.config.onConflict) { - const conflict: Conflict = { - entity: item.entity, - localItem: item, - remoteData, - }; - return await this.config.onConflict(conflict); - } - // No handler — fall back to server-wins - return remoteData; - } - - default: - return remoteData; - } - } - - // ─────────────────────────────────────────────────────────────────────────── - // Connectivity Detection - // ─────────────────────────────────────────────────────────────────────────── - - private setupConnectivityDetection(): void { - if (typeof globalThis === 'undefined') return; - const win = typeof window !== 'undefined' ? window : undefined; - if (!win?.addEventListener) return; - - this.onlineHandler = () => { - this.setStatus('idle'); - void this.flush(); - }; - this.offlineHandler = () => { - this.setStatus('offline'); - }; - - win.addEventListener('online', this.onlineHandler); - win.addEventListener('offline', this.offlineHandler); - } - - private isOnline(): boolean { - if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') { - return navigator.onLine; - } - return true; // Assume online in non-browser environments (Node.js, SSR) - } - - async flush(): Promise { - if (this.destroyed || this.status === 'syncing') return; - const result = await this.pushQueue(); - if (result.success && result.errors === 0) { - this.setStatus('idle'); - } - } - - destroy(): void { - this.destroyed = true; - const win = typeof window !== 'undefined' ? window : undefined; - if (win) { - if (this.onlineHandler) win.removeEventListener('online', this.onlineHandler); - if (this.offlineHandler) win.removeEventListener('offline', this.offlineHandler); - } - this.statusListeners.clear(); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Status & Monitoring - // ─────────────────────────────────────────────────────────────────────────── - - getQueueLength(): number { - return this.queueLength; - } - - getStatus(): SyncStatusInfo { - return { - status: this.status, - queueLength: this.queueLength, - lastSyncAt: this.lastSyncAt, - lastError: this.lastError, - }; - } - - onStatusChange(callback: SyncStatusCallback): () => void { - this.statusListeners.add(callback); - return () => this.statusListeners.delete(callback); - } - - private setStatus(status: SyncStatus, error?: string): void { - this.status = status; - if (error) this.lastError = error; - this.notifyStatus(); - } - - private notifyStatus(): void { - const info: SyncStatusInfo = { - status: this.status, - queueLength: this.queueLength, - lastSyncAt: this.lastSyncAt, - lastError: this.lastError, - }; - this.statusListeners.forEach(cb => cb(info)); - } - - // ─────────────────────────────────────────────────────────────────────────── - // Utility - // ─────────────────────────────────────────────────────────────────────────── - - async clearQueue(): Promise { - await this.saveQueue([]); - this.notifyStatus(); - } - - async reprocessFailed(): Promise { - const queue = await this.getQueue(); - const reset = queue.map(item => ({ - ...item, - retryCount: 0, - lastError: undefined, - })); - await this.saveQueue(reset); - await this.flush(); - } - - private async getLastSyncTime(): Promise { - return (await this.config.storage.getItem(LAST_SYNC_KEY)) || undefined; - } - - private async setLastSyncTime(timestamp: string): Promise { - await this.config.storage.setItem(LAST_SYNC_KEY, timestamp); - } - - private generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - } - - private getDedupKey(entity: string, data: unknown): string { - const id = (data as { id?: string })?.id; - return id ? `${entity}:${id}` : `${entity}:${JSON.stringify(data)}`; - } - - private emptyResult(): SyncResult { - return { - success: true, - pushed: 0, - pulled: 0, - conflicts: 0, - errors: 0, - timestamp: new Date().toISOString(), - }; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Error Classification - // ─────────────────────────────────────────────────────────────────────────── - - private isConflictError(error: unknown): boolean { - if (error instanceof SyncConflictError) return true; - const msg = error instanceof Error ? error.message : String(error); - return msg.includes('409') || msg.includes('conflict'); - } - - private isNonRetriable(error: unknown): boolean { - const msg = error instanceof Error ? error.message : String(error); - // 4xx errors (except 408, 429) are non-retriable - return /\b(400|401|403|404|405|406|410|422)\b/.test(msg); - } - - private extractRemoteData(error: unknown): unknown { - if (error instanceof SyncConflictError) return error.remoteData; - return undefined; - } - - // ─────────────────────────────────────────────────────────────────────────── - // Telemetry - // ─────────────────────────────────────────────────────────────────────────── - - private trackTelemetry( - eventName: string, - entity: string, - error?: unknown, - extra?: Record - ): void { - if (!this.config.telemetryClient) return; - try { - this.config.telemetryClient.trackEvent('sync', 'sync-engine', eventName, { - tags: { - productId: this.config.productId, - entity, - ...(error ? { error: error instanceof Error ? error.message : String(error) } : {}), - }, - metrics: extra as Record | undefined, - }); - } catch { - // Telemetry should never break sync - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Factory Function -// ───────────────────────────────────────────────────────────────────────────── - -export function createSyncEngine(config: SyncEngineConfig): SyncEngine { - return new SyncEngineImpl(config); -} diff --git a/vendor/bytelyst/sync/src/index.ts b/vendor/bytelyst/sync/src/index.ts deleted file mode 100644 index e49e961..0000000 --- a/vendor/bytelyst/sync/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @bytelyst/sync - * - * Offline-first sync engine with configurable storage adapters and conflict resolution. - * - * @example - * ```typescript - * import { createSyncEngine, LocalStorageAdapter } from '@bytelyst/sync'; - * import { createApiClient } from '@bytelyst/api-client'; - * - * const sync = createSyncEngine({ - * productId: 'myapp', - * entities: { - * tasks: { - * endpoint: '/api/tasks', - * partitionKey: 'userId', - * conflictStrategy: 'server-wins', - * }, - * }, - * storage: new LocalStorageAdapter(), - * apiClient: createApiClient({ baseURL: 'https://api.example.com' }), - * }); - * - * // Push changes - * await sync.push('tasks', { title: 'New Task' }); - * - * // Sync with server - * const result = await sync.fullSync(); - * console.log(`Pushed: ${result.pushed}, Pulled: ${result.pulled}`); - * ``` - */ - -export { createSyncEngine, SyncEngineImpl, SyncConflictError, computeBackoff } from './engine.js'; - -export { LocalStorageAdapter, InMemoryAdapter, MMKVAdapter, type MMKVInstance } from './storage.js'; - -export type { - SyncEngine, - SyncEngineConfig, - EntityName, - EntityConfig, - ConflictStrategy, - SyncStatus, - SyncOperation, - SyncItem, - SyncResult, - SyncStatusInfo, - SyncStatusCallback, - PullHandler, - StorageAdapter, - Conflict, -} from './types.js'; diff --git a/vendor/bytelyst/sync/src/storage.ts b/vendor/bytelyst/sync/src/storage.ts deleted file mode 100644 index eb902f0..0000000 --- a/vendor/bytelyst/sync/src/storage.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Storage Adapters - * - * @module @bytelyst/sync/storage - */ - -import type { StorageAdapter } from './types.js'; - -// ───────────────────────────────────────────────────────────────────────────── -// LocalStorage Adapter (Web) -// ───────────────────────────────────────────────────────────────────────────── - -export class LocalStorageAdapter implements StorageAdapter { - private prefix: string; - - constructor(prefix = 'bytelyst:sync:') { - this.prefix = prefix; - } - - getItem(key: string): T | null { - if (typeof localStorage === 'undefined') return null; - const value = localStorage.getItem(this.prefix + key); - if (!value) return null; - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - - setItem(key: string, value: T): void { - if (typeof localStorage === 'undefined') return; - localStorage.setItem(this.prefix + key, JSON.stringify(value)); - } - - removeItem(key: string): void { - if (typeof localStorage === 'undefined') return; - localStorage.removeItem(this.prefix + key); - } - - keys(): string[] { - if (typeof localStorage === 'undefined') return []; - const keys: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith(this.prefix)) { - keys.push(key.slice(this.prefix.length)); - } - } - return keys; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// In-Memory Adapter (Testing / SSR) -// ───────────────────────────────────────────────────────────────────────────── - -export class InMemoryAdapter implements StorageAdapter { - private store = new Map(); - - getItem(key: string): T | null { - const value = this.store.get(key); - return value !== undefined ? (value as T) : null; - } - - setItem(key: string, value: T): void { - this.store.set(key, value); - } - - removeItem(key: string): void { - this.store.delete(key); - } - - keys(): string[] { - return Array.from(this.store.keys()); - } - - clear(): void { - this.store.clear(); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// MMKV Adapter Interface (React Native) -// ───────────────────────────────────────────────────────────────────────────── - -export interface MMKVInstance { - getString(key: string): string | undefined; - set(key: string, value: string): void; - delete(key: string): void; - getAllKeys(): string[]; -} - -export class MMKVAdapter implements StorageAdapter { - private mmkv: MMKVInstance; - private prefix: string; - - constructor(mmkv: MMKVInstance, prefix = 'sync:') { - this.mmkv = mmkv; - this.prefix = prefix; - } - - getItem(key: string): T | null { - const value = this.mmkv.getString(this.prefix + key); - if (!value) return null; - try { - return JSON.parse(value) as T; - } catch { - return null; - } - } - - setItem(key: string, value: T): void { - this.mmkv.set(this.prefix + key, JSON.stringify(value)); - } - - removeItem(key: string): void { - this.mmkv.delete(this.prefix + key); - } - - keys(): string[] { - return this.mmkv - .getAllKeys() - .filter(k => k.startsWith(this.prefix)) - .map(k => k.slice(this.prefix.length)); - } -} diff --git a/vendor/bytelyst/sync/src/sync.test.ts b/vendor/bytelyst/sync/src/sync.test.ts deleted file mode 100644 index ce876b0..0000000 --- a/vendor/bytelyst/sync/src/sync.test.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Sync Engine Tests — 25+ tests - * - * Covers: queue persistence, retry with backoff, conflict resolution (all 4 - * strategies), deduplication, connectivity, onPull callback, telemetry, - * delete consolidation, multiple entities, status monitoring, destroy. - * - * @module @bytelyst/sync/sync.test - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js'; -import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js'; -import type { ApiClient, ApiResult } from '@bytelyst/api-client'; -import type { TelemetryClient } from '@bytelyst/telemetry-client'; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -interface MockApiClient extends ApiClient { - getRequests(): { path: string; options?: RequestInit }[]; - setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void; - setSafeFetchBehavior(fn: (path: string) => unknown): void; -} - -function createMockApiClient(): MockApiClient { - const requests: { path: string; options?: RequestInit }[] = []; - let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null; - let safeFetchBehavior: ((path: string) => unknown) | null = null; - - return { - fetch: async (path: string, options?: RequestInit): Promise => { - requests.push({ path, options }); - if (fetchBehavior) return fetchBehavior(path, options) as T; - return {} as T; - }, - safeFetch: async (path: string, options?: RequestInit): Promise> => { - requests.push({ path, options }); - if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult; - return { data: { items: [] } as unknown as T, error: null }; - }, - getRequests: () => requests, - setFetchBehavior: fn => { - fetchBehavior = fn; - }, - setSafeFetchBehavior: fn => { - safeFetchBehavior = fn; - }, - }; -} - -function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } { - const events: { eventName: string }[] = []; - return { - init: vi.fn(), - trackEvent: (eventType: string, module: string, eventName: string) => { - events.push({ eventName }); - }, - flush: vi.fn(), - shutdown: vi.fn(), - getInstallId: () => 'test-install', - getSessionId: () => 'test-session', - events, - }; -} - -const TASKS_ENTITY: EntityConfig = { - endpoint: '/tasks', - partitionKey: 'userId', - conflictStrategy: 'server-wins', -}; - -function makeConfig( - storage: InMemoryAdapter, - apiClient: MockApiClient, - overrides?: Partial -): SyncEngineConfig { - return { - productId: 'test', - entities: { tasks: TASKS_ENTITY }, - storage, - apiClient, - maxRetries: 3, - retryBaseDelayMs: 1, // fast for tests - retryMaxDelayMs: 10, - ...overrides, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Storage Adapter Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe('InMemoryAdapter', () => { - it('stores and retrieves items', () => { - const s = new InMemoryAdapter(); - s.setItem('k', { x: 1 }); - expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 }); - }); - - it('returns null for missing keys', () => { - expect(new InMemoryAdapter().getItem('nope')).toBeNull(); - }); - - it('lists all keys', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.setItem('b', 2); - expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b'])); - }); - - it('removes items', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.removeItem('a'); - expect(s.getItem('a')).toBeNull(); - }); - - it('clears all items', () => { - const s = new InMemoryAdapter(); - s.setItem('a', 1); - s.setItem('b', 2); - s.clear(); - expect(s.keys()).toHaveLength(0); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// computeBackoff -// ───────────────────────────────────────────────────────────────────────────── - -describe('computeBackoff', () => { - it('returns increasing delays', () => { - const d0 = computeBackoff(0, 1000, 30000); - const d1 = computeBackoff(1, 1000, 30000); - const d2 = computeBackoff(2, 1000, 30000); - // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter) - expect(d0).toBeLessThan(d1); - expect(d1).toBeLessThan(d2); - }); - - it('caps at maxMs', () => { - const d = computeBackoff(20, 1000, 5000); - expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine — Core Operations -// ───────────────────────────────────────────────────────────────────────────── - -describe('Sync Engine', () => { - let storage: InMemoryAdapter; - let apiClient: MockApiClient; - - beforeEach(() => { - storage = new InMemoryAdapter(); - apiClient = createMockApiClient(); - }); - - // ─── Creation ────────────────────────────────────────────────────────── - - it('creates engine with all interface methods', () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - expect(engine.push).toBeTypeOf('function'); - expect(engine.delete).toBeTypeOf('function'); - expect(engine.pull).toBeTypeOf('function'); - expect(engine.fullSync).toBeTypeOf('function'); - expect(engine.getQueueLength).toBeTypeOf('function'); - expect(engine.getStatus).toBeTypeOf('function'); - expect(engine.onStatusChange).toBeTypeOf('function'); - expect(engine.clearQueue).toBeTypeOf('function'); - expect(engine.reprocessFailed).toBeTypeOf('function'); - expect(engine.flush).toBeTypeOf('function'); - expect(engine.destroy).toBeTypeOf('function'); - }); - - // ─── Queue Persistence ───────────────────────────────────────────────── - - it('persists queue across engine instances (simulated restart)', async () => { - const engine1 = createSyncEngine(makeConfig(storage, apiClient)); - await engine1.push('tasks', { id: 't1', title: 'persist me' }); - engine1.destroy(); - - // "Restart" — new engine, same storage - const engine2 = createSyncEngine(makeConfig(storage, apiClient)); - const result = await engine2.fullSync(); - expect(result.pushed).toBe(1); - - const reqs = apiClient.getRequests(); - const postReq = reqs.find(r => r.options?.method === 'POST'); - expect(postReq).toBeDefined(); - expect(postReq!.path).toBe('/tasks'); - }); - - // ─── Push & Deduplication ────────────────────────────────────────────── - - it('deduplicates updates to same entity+id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: '1', title: 'v1' }, 'update'); - await engine.push('tasks', { id: '1', title: 'v2' }, 'update'); - expect(engine.getQueueLength()).toBe(1); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - - // The last value should be sent - const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH'); - expect(patchReq).toBeDefined(); - expect(patchReq!.path).toBe('/tasks/1'); - const body = JSON.parse(patchReq!.options!.body as string); - expect(body.title).toBe('v2'); - }); - - it('does not deduplicate different operations on same id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: '1', title: 'create' }, 'create'); - await engine.push('tasks', { id: '1', title: 'update' }, 'update'); - expect(engine.getQueueLength()).toBe(2); - }); - - it('does not deduplicate items without id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'Task A' }); - await engine.push('tasks', { title: 'Task B' }); - expect(engine.getQueueLength()).toBe(2); - }); - - // ─── Delete ──────────────────────────────────────────────────────────── - - it('delete removes pending create/update for same entity+id', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: 'x', title: 'created' }); - await engine.push('tasks', { id: 'x', title: 'updated' }, 'update'); - // Now delete should collapse the above - await engine.delete('tasks', 'x'); - expect(engine.getQueueLength()).toBe(1); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE'); - expect(delReq).toBeDefined(); - expect(delReq!.path).toBe('/tasks/x'); - }); - - // ─── Pull + onPull Callback ──────────────────────────────────────────── - - it('invokes onPull with pulled items', async () => { - const pulled: { entity: string; items: unknown[] }[] = []; - apiClient.setSafeFetchBehavior(() => ({ - data: { items: [{ id: 'r1', title: 'Remote Task' }] }, - error: null, - })); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - onPull: (entity, items) => { - pulled.push({ entity, items }); - }, - }) - ); - - const result = await engine.pull(); - expect(result.pulled).toBe(1); - expect(pulled).toHaveLength(1); - expect(pulled[0].entity).toBe('tasks'); - expect(pulled[0].items).toHaveLength(1); - }); - - it('pull appends ?since= parameter after first sync', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - - await engine.pull(); // first pull — no since - const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks')); - expect(firstReq!.path).toBe('/tasks'); - - await engine.pull(); // second pull — should have since= - const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks')); - const secondReq = allReqs[allReqs.length - 1]; - expect(secondReq.path).toContain('?since='); - }); - - // ─── fullSync ────────────────────────────────────────────────────────── - - it('fullSync pushes then pulls', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { id: 't1', title: 'local' }); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(1); - expect(engine.getQueueLength()).toBe(0); - expect(engine.getStatus().lastSyncAt).toBeTruthy(); - }); - - // ─── Retry with Backoff ──────────────────────────────────────────────── - - it('retries on transient errors and keeps item in queue', async () => { - let callCount = 0; - apiClient.setFetchBehavior(() => { - callCount++; - throw new Error('500 Internal Server Error'); - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 })); - await engine.push('tasks', { id: 'fail', title: 'will fail' }); - - await engine.flush(); - - // Item should still be in queue with incremented retryCount - expect(engine.getQueueLength()).toBe(1); - // Multiple fetch attempts were made (backoff retries within pushItemWithRetry) - expect(callCount).toBeGreaterThan(1); - }); - - it('drops item after exceeding maxRetries', async () => { - apiClient.setFetchBehavior(() => { - throw new Error('500'); - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); - await engine.push('tasks', { id: 'drop', title: 'drop me' }); - - // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount - await engine.flush(); - // Second flush: retryCount >= maxRetries → dropped - await engine.flush(); - - expect(engine.getQueueLength()).toBe(0); - }); - - // ─── Conflict Resolution ─────────────────────────────────────────────── - - it('server-wins: accepts remote data on conflict', async () => { - const pulled: unknown[][] = []; - apiClient.setFetchBehavior(() => { - throw new SyncConflictError({ id: 'c1', title: 'Server Version' }); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - }, - onPull: (_entity, items) => { - pulled.push(items); - }, - }) - ); - - await engine.push('tasks', { id: 'c1', title: 'Client Version' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - // server-wins: onPull should have been called with remote data - expect(pulled.length).toBeGreaterThanOrEqual(1); - }); - - it('client-wins: re-pushes local data on conflict', async () => { - let callIdx = 0; - apiClient.setFetchBehavior(() => { - callIdx++; - if (callIdx === 1) { - throw new SyncConflictError({ id: 'c2', title: 'Server' }); - } - return {}; // Second call (PUT) succeeds - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' }, - }, - }) - ); - - await engine.push('tasks', { id: 'c2', title: 'Client' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - expect(result.pushed).toBe(1); // conflict resolved → counted as pushed - // Should have made a PUT request with client data - const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); - expect(putReq).toBeDefined(); - }); - - it('last-write-wins: picks newer timestamp', async () => { - const pulled: unknown[][] = []; - const oldDate = '2020-01-01T00:00:00.000Z'; - apiClient.setFetchBehavior(() => { - throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate }); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { - endpoint: '/tasks', - partitionKey: 'userId', - conflictStrategy: 'last-write-wins', - }, - }, - onPull: (_entity, items) => { - pulled.push(items); - }, - }) - ); - - // Client push will have a newer timestamp than 2020 - await engine.push('tasks', { id: 'c3', title: 'Client Newer' }); - const result = await engine.fullSync(); - expect(result.conflicts).toBe(1); - // Client is newer → should NOT have called onPull with server data - // Instead it should have re-pushed (PUT) - const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); - expect(putReq).toBeDefined(); - }); - - it('manual: calls onConflict handler', async () => { - apiClient.setFetchBehavior((_path, options) => { - const method = options?.method; - if (method === 'POST') { - throw new SyncConflictError({ id: 'c4', title: 'Server' }); - } - return {}; - }); - - const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' }, - }, - onConflict, - }) - ); - - await engine.push('tasks', { id: 'c4', title: 'Client' }); - const result = await engine.fullSync(); - - expect(result.conflicts).toBe(1); - expect(onConflict).toHaveBeenCalledTimes(1); - expect(onConflict.mock.calls[0][0]).toMatchObject({ - entity: 'tasks', - remoteData: { id: 'c4', title: 'Server' }, - }); - }); - - // ─── Multiple Entities ───────────────────────────────────────────────── - - it('handles multiple entity types', async () => { - const pulled: { entity: string; items: unknown[] }[] = []; - apiClient.setSafeFetchBehavior(path => { - if (path.startsWith('/tasks')) { - return { data: { items: [{ id: 't1' }] }, error: null }; - } - if (path.startsWith('/notes')) { - return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null }; - } - return { data: { items: [] }, error: null }; - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - entities: { - tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, - notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' }, - }, - onPull: (entity, items) => { - pulled.push({ entity, items }); - }, - }) - ); - - await engine.push('tasks', { id: 't-new', title: 'Task' }); - await engine.push('notes', { id: 'n-new', body: 'Note' }); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(2); - expect(result.pulled).toBe(3); // 1 task + 2 notes - expect(pulled).toHaveLength(2); - }); - - // ─── Status Monitoring ───────────────────────────────────────────────── - - it('returns correct initial status', () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const status = engine.getStatus(); - expect(status.status).toBe('idle'); - expect(status.queueLength).toBe(0); - expect(status.lastSyncAt).toBeUndefined(); - }); - - it('notifies listeners on status changes', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const statuses: SyncStatusInfo[] = []; - engine.onStatusChange(s => statuses.push({ ...s })); - - await engine.push('tasks', { id: 'x', title: 'X' }); - await engine.fullSync(); - - const statusNames = statuses.map(s => s.status); - expect(statusNames).toContain('syncing'); - expect(statusNames).toContain('idle'); - }); - - it('unsubscribe stops notifications', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - const statuses: string[] = []; - const unsub = engine.onStatusChange(s => statuses.push(s.status)); - - await engine.push('tasks', { title: 'A' }); - const countBefore = statuses.length; - - unsub(); - await engine.push('tasks', { title: 'B' }); - expect(statuses.length).toBe(countBefore); - }); - - // ─── clearQueue ──────────────────────────────────────────────────────── - - it('clearQueue empties the queue', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'A' }); - await engine.push('tasks', { title: 'B' }); - expect(engine.getQueueLength()).toBe(2); - - await engine.clearQueue(); - expect(engine.getQueueLength()).toBe(0); - - const result = await engine.fullSync(); - expect(result.pushed).toBe(0); - }); - - // ─── reprocessFailed ─────────────────────────────────────────────────── - - it('reprocessFailed resets retry counts and re-flushes', async () => { - let failCount = 0; - apiClient.setFetchBehavior(() => { - failCount++; - if (failCount <= 2) throw new Error('transient'); - return {}; - }); - - const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); - await engine.push('tasks', { id: 'rp', title: 'reprocess' }); - await engine.flush(); // fails, item stays in queue - - expect(engine.getQueueLength()).toBe(1); - - // Now make API succeed and reprocess - apiClient.setFetchBehavior(() => ({})); - await engine.reprocessFailed(); - expect(engine.getQueueLength()).toBe(0); - }); - - // ─── Telemetry Integration ───────────────────────────────────────────── - - it('tracks sync events via telemetry client', async () => { - const telemetry = createMockTelemetry(); - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - telemetryClient: telemetry, - }) - ); - - await engine.push('tasks', { id: 't1', title: 'test' }); - await engine.fullSync(); - - const eventNames = telemetry.events.map(e => e.eventName); - expect(eventNames).toContain('sync_push_success'); - expect(eventNames).toContain('sync_complete'); - }); - - it('telemetry tracks push errors', async () => { - const telemetry = createMockTelemetry(); - apiClient.setFetchBehavior(() => { - throw new Error('400 Bad Request'); - }); - - const engine = createSyncEngine( - makeConfig(storage, apiClient, { - telemetryClient: telemetry, - maxRetries: 1, - }) - ); - - await engine.push('tasks', { id: 'bad', title: 'fail' }); - await engine.flush(); - await engine.flush(); // second flush drops item - - const eventNames = telemetry.events.map(e => e.eventName); - expect(eventNames).toContain('sync_push_dropped'); - }); - - // ─── Destroy ─────────────────────────────────────────────────────────── - - it('destroy prevents further flush', async () => { - const engine = createSyncEngine(makeConfig(storage, apiClient)); - await engine.push('tasks', { title: 'orphan' }); - engine.destroy(); - - await engine.flush(); // should be no-op after destroy - // Item still in queue (flush was no-op) - expect(engine.getQueueLength()).toBe(1); - }); -}); diff --git a/vendor/bytelyst/sync/src/types.ts b/vendor/bytelyst/sync/src/types.ts deleted file mode 100644 index e8fc60d..0000000 --- a/vendor/bytelyst/sync/src/types.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Sync Engine Types - * - * @module @bytelyst/sync/types - */ - -import type { ApiClient } from '@bytelyst/api-client'; -import type { TelemetryClient } from '@bytelyst/telemetry-client'; - -// ───────────────────────────────────────────────────────────────────────────── -// Core Types -// ───────────────────────────────────────────────────────────────────────────── - -export type EntityName = string; - -export type ConflictStrategy = 'server-wins' | 'client-wins' | 'last-write-wins' | 'manual'; - -export type SyncStatus = 'idle' | 'syncing' | 'offline' | 'error'; - -export type SyncOperation = 'create' | 'update' | 'delete'; - -export interface EntityConfig { - /** REST endpoint path, e.g. '/api/timers' */ - endpoint: string; - /** Cosmos partition key field name (for reference) */ - partitionKey: string; - /** Conflict resolution strategy */ - conflictStrategy: ConflictStrategy; -} - -/** - * Callback invoked when items are pulled from the server. - * Consumer is responsible for merging pulled data into their local store. - */ -export type PullHandler = (entity: EntityName, items: unknown[]) => void | Promise; - -export interface SyncEngineConfig { - productId: string; - entities: Record; - storage: StorageAdapter; - apiClient: ApiClient; - telemetryClient?: TelemetryClient; - /** Called when items are pulled from server — consumer merges into local store */ - onPull?: PullHandler; - /** Called for 'manual' conflict strategy. Return the winning data. */ - onConflict?: (conflict: Conflict) => Promise | unknown; - /** Max retry attempts before dropping an item. Default: 5. */ - maxRetries?: number; - /** Base delay in ms for exponential backoff. Default: 1000. */ - retryBaseDelayMs?: number; - /** Maximum backoff delay in ms. Default: 30000. */ - retryMaxDelayMs?: number; -} - -export interface SyncItem { - id: string; - entity: EntityName; - operation: SyncOperation; - data: unknown; - timestamp: string; - retryCount: number; - lastError?: string; -} - -export interface SyncResult { - success: boolean; - pushed: number; - pulled: number; - conflicts: number; - errors: number; - timestamp: string; -} - -export interface SyncStatusInfo { - status: SyncStatus; - queueLength: number; - lastSyncAt?: string; - lastError?: string; -} - -export type SyncStatusCallback = (status: SyncStatusInfo) => void; - -export interface Conflict { - entity: EntityName; - localItem: SyncItem; - remoteData: unknown; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Storage Adapter Interface -// ───────────────────────────────────────────────────────────────────────────── - -export interface StorageAdapter { - getItem(key: string): Promise | T | null; - setItem(key: string, value: T): Promise | void; - removeItem(key: string): Promise | void; - keys(): Promise | string[]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Sync Engine Interface -// ───────────────────────────────────────────────────────────────────────────── - -export interface SyncEngine { - /** Queue a create/update for later push. Deduplicates by entity + data.id. */ - push(entity: EntityName, data: unknown, operation?: SyncOperation): Promise; - /** Queue a delete for later push. */ - delete(entity: EntityName, id: string): Promise; - /** Pull remote changes for all entities. Invokes onPull callback. */ - pull(): Promise; - /** Push queued items, then pull remote changes. */ - fullSync(): Promise; - - /** Current number of items in the offline queue. */ - getQueueLength(): number; - /** Current sync status snapshot. */ - getStatus(): SyncStatusInfo; - /** Subscribe to status changes. Returns unsubscribe function. */ - onStatusChange(callback: SyncStatusCallback): () => void; - - /** Remove all items from the offline queue. */ - clearQueue(): Promise; - /** Reset retry counts on all failed items and re-flush. */ - reprocessFailed(): Promise; - /** Manually trigger a flush of the push queue. */ - flush(): Promise; - /** Tear down connectivity listeners. */ - destroy(): void; -} diff --git a/vendor/bytelyst/sync/tsconfig.json b/vendor/bytelyst/sync/tsconfig.json deleted file mode 100644 index 2118468..0000000 --- a/vendor/bytelyst/sync/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/sync/vitest.config.ts b/vendor/bytelyst/sync/vitest.config.ts deleted file mode 100644 index 5e0718b..0000000 --- a/vendor/bytelyst/sync/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - pool: 'forks', - include: ['src/**/*.test.ts'], - coverage: { - reporter: ['text', 'json', 'html'], - include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/index.ts'], - }, - }, -}); diff --git a/vendor/bytelyst/telemetry-client/package.json b/vendor/bytelyst/telemetry-client/package.json deleted file mode 100644 index 0ee6c1d..0000000 --- a/vendor/bytelyst/telemetry-client/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@bytelyst/telemetry-client", - "version": "0.1.5", - "type": "module", - "description": "Browser/React Native-safe telemetry client for platform-service", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "vitest run --pool forks" - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts b/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts deleted file mode 100644 index 33cc1d8..0000000 --- a/vendor/bytelyst/telemetry-client/src/__tests__/telemetry-client.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { createTelemetryClient } from '../client.js'; -import type { TelemetryStorage } from '../types.js'; - -function createMockStorage(): TelemetryStorage & { store: Map } { - const store = new Map(); - return { - store, - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, value), - }; -} - -describe('@bytelyst/telemetry-client', () => { - let storage: ReturnType; - - beforeEach(() => { - storage = createMockStorage(); - vi.restoreAllMocks(); - vi.useFakeTimers(); - globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('createTelemetryClient', () => { - it('creates a client with all expected methods', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.init).toBeTypeOf('function'); - expect(client.trackEvent).toBeTypeOf('function'); - expect(client.flush).toBeTypeOf('function'); - expect(client.shutdown).toBeTypeOf('function'); - expect(client.getInstallId).toBeTypeOf('function'); - expect(client.getSessionId).toBeTypeOf('function'); - }); - }); - - describe('install ID', () => { - it('generates and persists install ID', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - const id = client.getInstallId(); - expect(id).toBeTruthy(); - expect(storage.store.get('testapp_telemetry_install_id')).toBe(id); - }); - - it('reuses persisted install ID', () => { - storage.store.set('testapp_telemetry_install_id', 'existing-id'); - - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.getInstallId()).toBe('existing-id'); - }); - }); - - describe('init', () => { - it('generates a session ID on init', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - expect(client.getSessionId()).toBe(''); - client.init(); - expect(client.getSessionId()).toBeTruthy(); - }); - - it('tracks session_started event on init', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.init(); - // Flush to see the queued event - client.flush(); - - expect(globalThis.fetch).toHaveBeenCalled(); - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.productId).toBe('testapp'); - expect(body.events).toHaveLength(1); - expect(body.events[0].eventName).toBe('session_started'); - expect(body.events[0].platform).toBe('web'); - expect(body.events[0].channel).toBe('pwa'); - }); - }); - - describe('trackEvent', () => { - it('queues events and flushes via fetch', () => { - const client = createTelemetryClient({ - productId: 'chronomind', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - transport: 'fetch', - storage, - }); - - client.trackEvent('info', 'timer', 'timer_created', { - feature: 'countdown', - tags: { type: 'alarm' }, - metrics: { duration: 300 }, - }); - - client.flush(); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/telemetry/events'); - expect(opts.method).toBe('POST'); - expect(opts.headers['x-product-id']).toBe('chronomind'); - - const body = JSON.parse(opts.body); - expect(body.events).toHaveLength(1); - const ev = body.events[0]; - expect(ev.eventType).toBe('info'); - expect(ev.module).toBe('timer'); - expect(ev.eventName).toBe('timer_created'); - expect(ev.feature).toBe('countdown'); - expect(ev.tags.type).toBe('alarm'); - expect(ev.metrics.duration).toBe(300); - expect(ev.occurredAt).toBeTruthy(); - }); - - it('auto-flushes when queue reaches maxQueue', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'mobile', - channel: 'react_native', - transport: 'fetch', - maxQueue: 3, - storage, - }); - - // Don't init to avoid session_started - client.trackEvent('info', 'a', 'one'); - client.trackEvent('info', 'b', 'two'); - expect(globalThis.fetch).not.toHaveBeenCalled(); - - client.trackEvent('info', 'c', 'three'); - expect(globalThis.fetch).toHaveBeenCalledOnce(); - }); - }); - - describe('flush', () => { - it('does nothing when queue is empty', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.flush(); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - }); - - describe('periodic flush', () => { - it('flushes on interval', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - flushIntervalMs: 10_000, - storage, - }); - - client.init(); - (globalThis.fetch as ReturnType).mockClear(); - - client.trackEvent('info', 'test', 'event1'); - - expect(globalThis.fetch).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(10_000); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - }); - }); - - describe('shutdown', () => { - it('flushes remaining events and stops timer', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.init(); - (globalThis.fetch as ReturnType).mockClear(); - - client.trackEvent('info', 'test', 'event1'); - client.shutdown(); - - expect(globalThis.fetch).toHaveBeenCalledOnce(); - - // After shutdown, periodic flush should not fire - (globalThis.fetch as ReturnType).mockClear(); - client.trackEvent('info', 'test', 'event2'); - vi.advanceTimersByTime(60_000); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); - }); - - describe('userId passthrough', () => { - it('includes userId in event when provided', () => { - const client = createTelemetryClient({ - productId: 'testapp', - baseUrl: 'http://localhost:4003/api', - platform: 'web', - channel: 'pwa', - storage, - }); - - client.trackEvent('info', 'auth', 'login', { userId: 'user-123' }); - client.flush(); - - const body = JSON.parse((globalThis.fetch as ReturnType).mock.calls[0][1].body); - expect(body.events[0].userId).toBe('user-123'); - }); - }); -}); diff --git a/vendor/bytelyst/telemetry-client/src/client.ts b/vendor/bytelyst/telemetry-client/src/client.ts deleted file mode 100644 index 13f91e2..0000000 --- a/vendor/bytelyst/telemetry-client/src/client.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Browser/React Native-safe telemetry client for platform-service. - * - * Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard. - * No Node.js dependencies — uses globalThis.fetch and configurable storage. - * - * @example - * ```ts - * import { createTelemetryClient } from '@bytelyst/telemetry-client'; - * - * const telemetry = createTelemetryClient({ - * productId: 'chronomind', - * baseUrl: 'http://localhost:4003/api', - * platform: 'web', - * channel: 'pwa', - * transport: 'beacon', - * }); - * - * telemetry.init(); - * telemetry.trackEvent('info', 'timer', 'timer_created'); - * ``` - */ - -import type { - TelemetryClient, - TelemetryClientConfig, - TelemetryEvent, - TelemetryStorage, -} from './types.js'; - -// ── UUID helper (browser + RN safe) ────────────────────────────── - -function uuid(): string { - if (typeof globalThis.crypto?.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -// ── Noop storage ───────────────────────────────────────────────── - -const noopStorage: TelemetryStorage = { - getItem: () => null, - setItem: () => {}, -}; - -function getDefaultStorage(): TelemetryStorage { - if ( - typeof globalThis.localStorage !== 'undefined' && - typeof globalThis.localStorage?.getItem === 'function' - ) { - return globalThis.localStorage; - } - return noopStorage; -} - -// ── Factory ────────────────────────────────────────────────────── - -export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient { - const { - productId, - baseUrl, - endpoint = '/telemetry/events', - platform, - channel, - transport = 'fetch', - maxQueue = 50, - flushIntervalMs = 30_000, - appVersion = '0.0.0', - buildNumber = '0', - releaseChannel = 'dev', - osFamily = 'other', - osVersion = '', - } = config; - - const storage = config.storage ?? getDefaultStorage(); - const INSTALL_KEY = `${productId}_telemetry_install_id`; - - let queue: TelemetryEvent[] = []; - let sessionId = ''; - let installId = ''; - let flushTimer: ReturnType | null = null; - - function getInstallId(): string { - if (installId) return installId; - const stored = storage.getItem(INSTALL_KEY); - if (stored) { - installId = stored; - return installId; - } - installId = uuid(); - storage.setItem(INSTALL_KEY, installId); - return installId; - } - - function getSessionId(): string { - return sessionId; - } - - function flushViaBeacon(): void { - if (queue.length === 0) return; - const events = [...queue]; - queue = []; - - const body = JSON.stringify({ productId, events }); - const url = `${baseUrl}${endpoint}`; - - try { - const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body); - if (!sent) { - // Fallback to fetch - globalThis - .fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }, - body, - keepalive: true, - }) - .catch(() => {}); - } - } catch { - // Silently ignore telemetry failures - } - } - - function flushViaFetch(): void { - if (queue.length === 0) return; - const events = [...queue]; - queue = []; - - const body = JSON.stringify({ productId, events }); - const url = `${baseUrl}${endpoint}`; - - globalThis - .fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-product-id': productId, - 'x-request-id': uuid(), - }, - body, - }) - .catch(() => {}); - } - - function flush(): void { - if (transport === 'beacon') { - flushViaBeacon(); - } else { - flushViaFetch(); - } - } - - function trackEvent( - eventType: string, - module: string, - eventName: string, - extra?: { - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - userId?: string; - } - ): void { - const event: TelemetryEvent = { - id: uuid(), - productId, - anonymousInstallId: getInstallId(), - sessionId, - platform, - channel, - osFamily, - osVersion, - appVersion, - buildNumber, - releaseChannel, - eventType, - module, - eventName, - ...extra, - occurredAt: new Date().toISOString(), - }; - - queue.push(event); - - if (queue.length >= maxQueue) { - flush(); - } - } - - function init(): void { - sessionId = uuid(); - getInstallId(); - - // Auto-flush on visibility change (web only) - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - flush(); - } - }); - } - - // Periodic flush - if (flushTimer) clearInterval(flushTimer); - flushTimer = setInterval(flush, flushIntervalMs); - - trackEvent('info', 'app_lifecycle', 'session_started'); - } - - function shutdown(): void { - flush(); - if (flushTimer) { - clearInterval(flushTimer); - flushTimer = null; - } - } - - return { - init, - trackEvent, - flush, - shutdown, - getInstallId, - getSessionId, - }; -} diff --git a/vendor/bytelyst/telemetry-client/src/index.ts b/vendor/bytelyst/telemetry-client/src/index.ts deleted file mode 100644 index ee02c77..0000000 --- a/vendor/bytelyst/telemetry-client/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createTelemetryClient } from './client.js'; -export { createWebTelemetry, type WebTelemetryConfig } from './web.js'; -export type { - TelemetryClient, - TelemetryClientConfig, - TelemetryEvent, - TelemetryStorage, -} from './types.js'; diff --git a/vendor/bytelyst/telemetry-client/src/types.ts b/vendor/bytelyst/telemetry-client/src/types.ts deleted file mode 100644 index 519b99c..0000000 --- a/vendor/bytelyst/telemetry-client/src/types.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Types for @bytelyst/telemetry-client. - * Browser/React Native-safe — no Node.js dependencies. - */ - -export interface TelemetryClientConfig { - /** Product identifier (e.g. 'chronomind', 'nomgap', 'lysnrai'). */ - productId: string; - - /** Platform-service base URL or telemetry ingest endpoint base. */ - baseUrl: string; - - /** Endpoint path appended to baseUrl. Default: '/telemetry/events'. */ - endpoint?: string; - - /** Platform identifier (e.g. 'web', 'mobile', 'desktop'). */ - platform: string; - - /** Channel identifier (e.g. 'pwa', 'react_native', 'web_app'). */ - channel: string; - - /** Transport: 'beacon' uses sendBeacon (web), 'fetch' uses fetch (RN/fallback). Default: 'fetch'. */ - transport?: 'beacon' | 'fetch'; - - /** Max events to queue before auto-flush. Default: 50. */ - maxQueue?: number; - - /** Flush interval in milliseconds. Default: 30000. */ - flushIntervalMs?: number; - - /** App version string. Default: '0.0.0'. */ - appVersion?: string; - - /** Build number. Default: '0'. */ - buildNumber?: string; - - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - - /** OS family. Default: 'other'. */ - osFamily?: string; - - /** OS version. Default: ''. */ - osVersion?: string; - - /** Storage adapter for install ID persistence. Uses localStorage by default. */ - storage?: TelemetryStorage; -} - -export interface TelemetryStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface TelemetryEvent { - id: string; - productId: string; - userId?: string; - anonymousInstallId: string; - sessionId: string; - platform: string; - channel: string; - osFamily: string; - osVersion: string; - appVersion: string; - buildNumber: string; - releaseChannel: string; - eventType: string; - module: string; - eventName: string; - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - occurredAt: string; -} - -export interface TelemetryClient { - /** Initialize the telemetry client and start periodic flushing. */ - init(): void; - - /** Track a telemetry event. */ - trackEvent( - eventType: string, - module: string, - eventName: string, - extra?: { - feature?: string; - message?: string; - tags?: Record; - metrics?: Record; - userId?: string; - } - ): void; - - /** Flush all queued events immediately. */ - flush(): void; - - /** Stop the periodic flush timer and flush remaining events. */ - shutdown(): void; - - /** Get the anonymous install ID. */ - getInstallId(): string; - - /** Get the current session ID. */ - getSessionId(): string; -} diff --git a/vendor/bytelyst/telemetry-client/src/web.ts b/vendor/bytelyst/telemetry-client/src/web.ts deleted file mode 100644 index 47468d9..0000000 --- a/vendor/bytelyst/telemetry-client/src/web.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Convenience factory for web dashboard telemetry. - * - * Eliminates ~30 lines of boilerplate per web app by wrapping - * createTelemetryClient() with sensible web defaults. - * - * @example - * ```ts - * import { createWebTelemetry } from '@bytelyst/telemetry-client'; - * - * const { client, init, trackPageView } = createWebTelemetry({ - * productId: 'nomgap', - * channel: 'nomgap_web', - * }); - * export { client as telemetryClient, init as initTelemetry, trackPageView }; - * ``` - */ - -import { createTelemetryClient } from './client.js'; -import type { TelemetryClient } from './types.js'; - -export interface WebTelemetryConfig { - /** Product identifier (e.g. 'nomgap', 'chronomind'). */ - productId: string; - /** Channel identifier (e.g. 'nomgap_web', 'pwa'). */ - channel: string; - /** Platform-service base URL. Default: 'http://localhost:4003/api'. */ - baseUrl?: string; - /** Telemetry ingest endpoint path. Default: '/telemetry/events'. */ - endpoint?: string; - /** Transport: 'beacon' or 'fetch'. Default: 'fetch'. */ - transport?: 'beacon' | 'fetch'; - /** App version string. Default: '0.1.0'. */ - appVersion?: string; - /** Build number. Default: '1'. */ - buildNumber?: string; - /** Release channel. Default: 'dev'. */ - releaseChannel?: string; - /** OS family. Default: 'other'. */ - osFamily?: string; -} - -export interface WebTelemetry { - /** The underlying telemetry client instance. */ - client: TelemetryClient; - /** Initialize telemetry and track app_initialized event. Idempotent. */ - init(): TelemetryClient; - /** Track a page view event. */ - trackPageView(page: string): void; -} - -export function createWebTelemetry(config: WebTelemetryConfig): WebTelemetry { - let initialized = false; - - const client = createTelemetryClient({ - productId: config.productId, - baseUrl: config.baseUrl ?? 'http://localhost:4003/api', - endpoint: config.endpoint ?? '/telemetry/events', - platform: 'web', - channel: config.channel, - transport: config.transport ?? 'fetch', - appVersion: config.appVersion ?? '0.1.0', - buildNumber: config.buildNumber ?? '1', - releaseChannel: config.releaseChannel ?? 'dev', - osFamily: config.osFamily ?? 'other', - }); - - function init(): TelemetryClient { - if (initialized) return client; - client.init(); - client.trackEvent('info', 'app_shell', 'web_app_initialized'); - initialized = true; - return client; - } - - function trackPageView(page: string): void { - client.trackEvent('info', 'navigation', 'page_view', { feature: page }); - } - - return { client, init, trackPageView }; -} diff --git a/vendor/bytelyst/telemetry-client/tsconfig.json b/vendor/bytelyst/telemetry-client/tsconfig.json deleted file mode 100644 index 318c075..0000000 --- a/vendor/bytelyst/telemetry-client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "lib": ["ES2022", "DOM"] - }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/testing/package.json b/vendor/bytelyst/testing/package.json deleted file mode 100644 index 0e819bc..0000000 --- a/vendor/bytelyst/testing/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@bytelyst/testing", - "version": "0.1.5", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "pretest": "pnpm --dir ../.. --filter @bytelyst/fastify-core build", - "build": "tsc", - "test": "vitest run --pool forks" - }, - "devDependencies": { - "@bytelyst/fastify-core": "workspace:*", - "fastify": "^5.2.1" - }, - "peerDependencies": { - "vitest": ">=3.0.0", - "fastify": ">=5.0.0" - }, - "peerDependenciesMeta": { - "fastify": { - "optional": true - } - }, - "publishConfig": { - "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" - } -} diff --git a/vendor/bytelyst/testing/src/__tests__/testing.test.ts b/vendor/bytelyst/testing/src/__tests__/testing.test.ts deleted file mode 100644 index 4112315..0000000 --- a/vendor/bytelyst/testing/src/__tests__/testing.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - createCosmosMocks, - TEST_JWT_SECRET, - TEST_USERS, - createTestTokenPayload, - injectGet, - expectHealthOk, -} from '../index.js'; -import { createServiceApp } from '@bytelyst/fastify-core'; - -describe('createCosmosMocks', () => { - it('returns all mock objects', () => { - const mocks = createCosmosMocks(); - expect(mocks.mockContainer).toBeDefined(); - expect(mocks.mockContainer.id).toBe('test-container'); - expect(mocks.mockDatabase).toBeDefined(); - expect(mocks.mockDatabases).toBeDefined(); - expect(mocks.MockCosmosClient).toBeDefined(); - expect(typeof mocks.resetMocks).toBe('function'); - }); - - it('MockCosmosClient returns database', () => { - const { MockCosmosClient, mockDatabase } = createCosmosMocks(); - const client = new MockCosmosClient('endpoint', 'key'); - expect(client.database('test-db')).toBe(mockDatabase); - }); - - it('mockContainer.items has CRUD methods', () => { - const { mockContainer } = createCosmosMocks(); - expect(typeof mockContainer.items.create).toBe('function'); - expect(typeof mockContainer.items.query).toBe('function'); - expect(typeof mockContainer.items.upsert).toBe('function'); - expect(typeof mockContainer.item).toBe('function'); - }); - - it('resetMocks clears all mock state', () => { - const { mockContainer, resetMocks } = createCosmosMocks(); - mockContainer.items.create({ id: '1' }); - expect(mockContainer.items.create).toHaveBeenCalledOnce(); - resetMocks(); - expect(mockContainer.items.create).not.toHaveBeenCalled(); - }); -}); - -describe('auth fixtures', () => { - it('TEST_JWT_SECRET is a non-empty string', () => { - expect(TEST_JWT_SECRET).toBeTruthy(); - expect(TEST_JWT_SECRET.length).toBeGreaterThanOrEqual(16); - }); - - it('TEST_USERS has admin, viewer, and user', () => { - expect(TEST_USERS.admin.email).toBe('admin@test.com'); - expect(TEST_USERS.viewer.role).toBe('viewer'); - expect(TEST_USERS.user.productId).toBe('lysnrai'); - }); - - it('createTestTokenPayload returns valid shape', () => { - const payload = createTestTokenPayload('admin'); - expect(payload.sub).toBe('user-admin-001'); - expect(payload.email).toBe('admin@test.com'); - expect(payload.role).toBe('super_admin'); - expect(payload.iss).toBe('test'); - expect(payload.exp).toBeGreaterThan(payload.iat); - }); - - it('createTestTokenPayload defaults to admin', () => { - const payload = createTestTokenPayload(); - expect(payload.sub).toBe('user-admin-001'); - }); -}); - -describe('fastify helpers', () => { - it('injectGet + expectHealthOk work with a real fastify-core app', async () => { - const app = await createServiceApp({ - name: 'test-svc', - version: '1.0.0', - logger: false, - }); - - const res = await injectGet(app, '/health'); - expect(res.statusCode).toBe(200); - expectHealthOk(res, 'test-svc'); - - await app.close(); - }); - - it('expectHealthOk throws on wrong service name', async () => { - const app = await createServiceApp({ - name: 'real-svc', - version: '1.0.0', - logger: false, - }); - - const res = await injectGet(app, '/health'); - expect(() => expectHealthOk(res, 'wrong-name')).toThrow('Expected service "wrong-name"'); - - await app.close(); - }); -}); diff --git a/vendor/bytelyst/testing/src/auth-fixtures.ts b/vendor/bytelyst/testing/src/auth-fixtures.ts deleted file mode 100644 index aaa94ad..0000000 --- a/vendor/bytelyst/testing/src/auth-fixtures.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Auth test fixtures — JWT tokens, user payloads, and password helpers. - * - * Usage: - * ```ts - * import { TEST_USERS, TEST_JWT_SECRET, createTestToken } from '@bytelyst/testing'; - * ``` - */ - -/** Test JWT secret — NEVER use in production */ -export const TEST_JWT_SECRET = 'test-jwt-secret-32-chars-long!!'; - -/** Pre-built test user payloads */ -export const TEST_USERS = { - admin: { - id: 'user-admin-001', - email: 'admin@test.com', - name: 'Test Admin', - role: 'super_admin', - productId: 'lysnrai', - }, - viewer: { - id: 'user-viewer-001', - email: 'viewer@test.com', - name: 'Test Viewer', - role: 'viewer', - productId: 'lysnrai', - }, - user: { - id: 'user-basic-001', - email: 'user@test.com', - name: 'Test User', - role: 'user', - productId: 'lysnrai', - }, -} as const; - -export type TestUserKey = keyof typeof TEST_USERS; - -/** - * Create a minimal JWT-like token payload for testing (not cryptographically signed). - * For actual JWT creation, use `@bytelyst/auth` createJwtUtils(). - */ -export function createTestTokenPayload(userKey: TestUserKey = 'admin') { - const user = TEST_USERS[userKey]; - return { - sub: user.id, - email: user.email, - role: user.role, - productId: user.productId, - iss: 'test', - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - }; -} diff --git a/vendor/bytelyst/testing/src/cosmos-mocks.ts b/vendor/bytelyst/testing/src/cosmos-mocks.ts deleted file mode 100644 index 86dd3f3..0000000 --- a/vendor/bytelyst/testing/src/cosmos-mocks.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Shared Cosmos DB mock factories for Vitest. - * - * Usage: - * ```ts - * import { createCosmosMocks } from '@bytelyst/testing'; - * const { mockContainer, mockDatabase, MockCosmosClient, resetMocks } = createCosmosMocks(); - * vi.mock('@azure/cosmos', () => ({ CosmosClient: MockCosmosClient })); - * ``` - */ - -import { vi } from 'vitest'; - -export interface MockItem { - id: string; - [key: string]: unknown; -} - -export interface CosmosMocks { - mockContainer: { - id: string; - items: { - create: ReturnType; - query: ReturnType; - upsert: ReturnType; - }; - item: ReturnType; - }; - mockDatabase: { - container: ReturnType; - containers: { createIfNotExists: ReturnType }; - }; - mockDatabases: { - createIfNotExists: ReturnType; - }; - MockCosmosClient: ReturnType; - resetMocks: () => void; -} - -/** - * Create a full set of Cosmos DB mocks for unit testing. - * - * Returns mock objects for container, database, and the CosmosClient constructor, - * plus a `resetMocks()` function to clear all mock state between tests. - */ -export function createCosmosMocks(): CosmosMocks { - const mockItem = { - read: vi.fn().mockResolvedValue({ resource: undefined }), - replace: vi.fn().mockResolvedValue({ resource: undefined }), - delete: vi.fn().mockResolvedValue({}), - patch: vi.fn().mockResolvedValue({ resource: undefined }), - }; - - const mockContainer = { - id: 'test-container', - items: { - create: vi.fn().mockResolvedValue({ resource: {} }), - query: vi.fn().mockReturnValue({ - fetchAll: vi.fn().mockResolvedValue({ resources: [] }), - }), - upsert: vi.fn().mockResolvedValue({ resource: {} }), - }, - item: vi.fn().mockReturnValue(mockItem), - }; - - const mockDatabase = { - container: vi.fn().mockReturnValue(mockContainer), - containers: { - createIfNotExists: vi.fn().mockResolvedValue({ container: mockContainer }), - }, - }; - - const mockDatabases = { - createIfNotExists: vi.fn().mockResolvedValue({ database: mockDatabase }), - }; - - const MockCosmosClient = vi.fn().mockImplementation(() => ({ - database: vi.fn().mockReturnValue(mockDatabase), - databases: mockDatabases, - })); - - function resetMocks() { - vi.clearAllMocks(); - } - - return { mockContainer, mockDatabase, mockDatabases, MockCosmosClient, resetMocks }; -} diff --git a/vendor/bytelyst/testing/src/fastify-helpers.ts b/vendor/bytelyst/testing/src/fastify-helpers.ts deleted file mode 100644 index 6673e55..0000000 --- a/vendor/bytelyst/testing/src/fastify-helpers.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Fastify test helpers — inject wrappers and assertion utilities. - * - * Usage: - * ```ts - * import { injectGet, injectPost, expectHealthOk } from '@bytelyst/testing'; - * const res = await injectGet(app, '/health'); - * expectHealthOk(res, 'platform-service'); - * ``` - */ - -import type { FastifyInstance } from 'fastify'; - -interface InjectResult { - statusCode: number; - payload: string; - headers: Record; - json: () => unknown; -} - -/** Inject a GET request into a Fastify instance */ -export async function injectGet( - app: FastifyInstance, - url: string, - headers?: Record -): Promise { - return app.inject({ method: 'GET', url, headers }) as unknown as Promise; -} - -/** Inject a POST request with JSON body into a Fastify instance */ -export async function injectPost( - app: FastifyInstance, - url: string, - body: unknown, - headers?: Record -): Promise { - return app.inject({ - method: 'POST', - url, - payload: body as string, - headers: { 'content-type': 'application/json', ...headers }, - }) as unknown as Promise; -} - -/** Inject a PATCH request with JSON body into a Fastify instance */ -export async function injectPatch( - app: FastifyInstance, - url: string, - body: unknown, - headers?: Record -): Promise { - return app.inject({ - method: 'PATCH', - url, - payload: body as string, - headers: { 'content-type': 'application/json', ...headers }, - }) as unknown as Promise; -} - -/** Inject a DELETE request into a Fastify instance */ -export async function injectDelete( - app: FastifyInstance, - url: string, - headers?: Record -): Promise { - return app.inject({ method: 'DELETE', url, headers }) as unknown as Promise; -} - -/** Assert a health check response has the correct shape */ -export function expectHealthOk(res: InjectResult, serviceName: string): void { - const body = JSON.parse(res.payload); - if (res.statusCode !== 200) throw new Error(`Expected 200, got ${res.statusCode}`); - if (body.status !== 'ok') throw new Error(`Expected status "ok", got "${body.status}"`); - if (body.service !== serviceName) - throw new Error(`Expected service "${serviceName}", got "${body.service}"`); - if (!body.timestamp) throw new Error('Missing timestamp in health response'); - if (!body.requestId) throw new Error('Missing requestId in health response'); -} - -export type { InjectResult }; diff --git a/vendor/bytelyst/testing/src/index.ts b/vendor/bytelyst/testing/src/index.ts deleted file mode 100644 index d7fcb05..0000000 --- a/vendor/bytelyst/testing/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { createCosmosMocks, type CosmosMocks, type MockItem } from './cosmos-mocks.js'; -export { - TEST_JWT_SECRET, - TEST_USERS, - createTestTokenPayload, - type TestUserKey, -} from './auth-fixtures.js'; -export { - injectGet, - injectPost, - injectPatch, - injectDelete, - expectHealthOk, - type InjectResult, -} from './fastify-helpers.js'; diff --git a/vendor/bytelyst/testing/tsconfig.json b/vendor/bytelyst/testing/tsconfig.json deleted file mode 100644 index c17685d..0000000 --- a/vendor/bytelyst/testing/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] -} diff --git a/vendor/bytelyst/time-references/package.json b/vendor/bytelyst/time-references/package.json deleted file mode 100644 index 78f132a..0000000 --- a/vendor/bytelyst/time-references/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@bytelyst/time-references", - "version": "0.1.5", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/vendor/bytelyst/time-references/src/client.test.ts b/vendor/bytelyst/time-references/src/client.test.ts deleted file mode 100644 index db45379..0000000 --- a/vendor/bytelyst/time-references/src/client.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { - getTimeReference, - getEpisodeComparison, - getEncouragingMessage, - registerReferences, - clearCustomReferences, -} from './client.js'; - -describe('getTimeReference', () => { - it('should return a reference for short duration', () => { - const ref = getTimeReference(0.1); - expect(ref.text.length).toBeGreaterThan(0); - expect(ref.emoji.length).toBeGreaterThan(0); - expect(['media', 'activity', 'travel', 'nature']).toContain(ref.category); - }); - - it('should return a reference for medium duration', () => { - const ref = getTimeReference(1.5); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should return a reference for long duration', () => { - const ref = getTimeReference(16); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should return a reference for very long duration', () => { - const ref = getTimeReference(72); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should handle zero hours', () => { - const ref = getTimeReference(0); - expect(ref.text.length).toBeGreaterThan(0); - }); - - it('should handle negative as zero', () => { - const ref = getTimeReference(-5); - expect(ref.text.length).toBeGreaterThan(0); - }); -}); - -describe('getEpisodeComparison', () => { - it('should return episode count for default show', () => { - const result = getEpisodeComparison(1); - expect(result).toContain('The Office'); - expect(result).toContain('episodes'); - }); - - it('should return custom show name', () => { - const result = getEpisodeComparison(2, 'Friends', 25); - expect(result).toContain('Friends'); - }); - - it('should handle less than one episode', () => { - const result = getEpisodeComparison(0.1); - expect(result).toContain('Less than one episode'); - }); - - it('should handle exactly one episode', () => { - const result = getEpisodeComparison(22 / 60); - expect(result).toContain('1 episode'); - }); -}); - -describe('getEncouragingMessage', () => { - it('should return message for each time bracket', () => { - expect(getEncouragingMessage(0.5).length).toBeGreaterThan(0); - expect(getEncouragingMessage(2).length).toBeGreaterThan(0); - expect(getEncouragingMessage(6).length).toBeGreaterThan(0); - expect(getEncouragingMessage(12).length).toBeGreaterThan(0); - expect(getEncouragingMessage(20).length).toBeGreaterThan(0); - expect(getEncouragingMessage(30).length).toBeGreaterThan(0); - }); -}); - -describe('registerReferences', () => { - afterEach(() => { - clearCustomReferences(); - }); - - it('should allow custom references', () => { - registerReferences([ - { - minHours: 0, - maxHours: 0.1, - references: [{ text: 'Custom micro reference', emoji: '⚡', category: 'activity' }], - }, - ]); - const ref = getTimeReference(0.05); - expect(ref.text).toBe('Custom micro reference'); - }); - - it('should clear custom references', () => { - registerReferences([ - { - minHours: 0, - maxHours: 0.1, - references: [{ text: 'Will be cleared', emoji: '🧹', category: 'activity' }], - }, - ]); - clearCustomReferences(); - const ref = getTimeReference(0.05); - expect(ref.text).not.toBe('Will be cleared'); - }); -}); diff --git a/vendor/bytelyst/time-references/src/client.ts b/vendor/bytelyst/time-references/src/client.ts deleted file mode 100644 index af153b7..0000000 --- a/vendor/bytelyst/time-references/src/client.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Familiar duration references for time-blindness aids. - * - * "About as long as a movie", "3 episodes of The Office". - * Pure client-side TS — no backend dependency. - */ - -import type { TimeRangeEntry, TimeReference } from './types.js'; - -const DEFAULT_DATABASE: TimeRangeEntry[] = [ - { - minHours: 0, - maxHours: 0.25, - references: [ - { text: 'About as long as a coffee break', emoji: '☕', category: 'activity' }, - { text: 'Like listening to a few songs', emoji: '🎵', category: 'media' }, - ], - }, - { - minHours: 0.25, - maxHours: 0.5, - references: [ - { text: 'About as long as a TV episode', emoji: '📺', category: 'media' }, - { text: 'Like a short walk around the block', emoji: '🚶', category: 'activity' }, - ], - }, - { - minHours: 0.5, - maxHours: 1, - references: [ - { text: 'About as long as a yoga class', emoji: '🧘', category: 'activity' }, - { text: 'Like watching 2 episodes of The Office', emoji: '📺', category: 'media' }, - ], - }, - { - minHours: 1, - maxHours: 2, - references: [ - { text: 'About as long as a movie', emoji: '🎬', category: 'media' }, - { text: 'Like a nice bike ride', emoji: '🚴', category: 'activity' }, - ], - }, - { - minHours: 2, - maxHours: 4, - references: [ - { text: 'About as long as a road trip to the next city', emoji: '🚗', category: 'travel' }, - { text: 'Like binge-watching a short series', emoji: '📺', category: 'media' }, - ], - }, - { - minHours: 4, - maxHours: 8, - references: [ - { text: 'About as long as a work day', emoji: '💼', category: 'activity' }, - { text: 'Like a full night of sleep', emoji: '😴', category: 'nature' }, - ], - }, - { - minHours: 8, - maxHours: 12, - references: [ - { text: 'About as long as a cross-country flight', emoji: '✈️', category: 'travel' }, - { text: 'Like sunrise to sunset in winter', emoji: '🌅', category: 'nature' }, - ], - }, - { - minHours: 12, - maxHours: 16, - references: [ - { text: 'About as long as daylight hours in summer', emoji: '☀️', category: 'nature' }, - { - text: 'Like watching the entire Lord of the Rings trilogy (extended)', - emoji: '🧙', - category: 'media', - }, - ], - }, - { - minHours: 16, - maxHours: 24, - references: [ - { text: 'Almost a full day — impressive!', emoji: '🌍', category: 'nature' }, - { text: 'Like a full day of hiking', emoji: '🥾', category: 'activity' }, - ], - }, - { - minHours: 24, - maxHours: 48, - references: [ - { text: 'More than a full day!', emoji: '🏆', category: 'nature' }, - { text: 'Like a weekend camping trip', emoji: '⛺', category: 'activity' }, - ], - }, - { - minHours: 48, - maxHours: Infinity, - references: [ - { text: 'An extraordinary duration — you are incredible!', emoji: '🌟', category: 'nature' }, - ], - }, -]; - -const FALLBACK: TimeReference = { - text: 'A meaningful amount of time', - emoji: '⏳', - category: 'nature', -}; - -const customRegistry: TimeRangeEntry[] = []; - -function findReferences(hours: number, custom: TimeRangeEntry[]): TimeReference[] { - for (const entry of custom) { - if (hours >= entry.minHours && hours < entry.maxHours) { - return entry.references; - } - } - for (const entry of DEFAULT_DATABASE) { - if (hours >= entry.minHours && hours < entry.maxHours) { - return entry.references; - } - } - return [FALLBACK]; -} - -export function getTimeReference(elapsedHours: number): TimeReference { - const refs = findReferences(Math.max(0, elapsedHours), customRegistry); - const index = Math.floor(Math.random() * refs.length); - return refs[index]; -} - -export function getEpisodeComparison( - elapsedHours: number, - showName = 'The Office', - episodeMins = 22 -): string { - const totalMins = elapsedHours * 60; - const episodes = Math.round(totalMins / episodeMins); - if (episodes <= 0) return `Less than one episode of ${showName}`; - if (episodes === 1) return `About 1 episode of ${showName}`; - return `About ${episodes} episodes of ${showName}`; -} - -export function getEncouragingMessage(elapsedHours: number): string { - if (elapsedHours < 1) return 'Every minute counts — great start!'; - if (elapsedHours < 4) return 'You are building momentum — keep going!'; - if (elapsedHours < 8) return 'Impressive dedication — halfway through the day!'; - if (elapsedHours < 16) return 'Amazing endurance — you are a champion!'; - if (elapsedHours < 24) return 'Nearly a full day — extraordinary commitment!'; - return 'Beyond a full day — you are truly remarkable!'; -} - -export function registerReferences(entries: TimeRangeEntry[]): void { - customRegistry.push(...entries); -} - -export function clearCustomReferences(): void { - customRegistry.length = 0; -} diff --git a/vendor/bytelyst/time-references/src/index.ts b/vendor/bytelyst/time-references/src/index.ts deleted file mode 100644 index 56d383e..0000000 --- a/vendor/bytelyst/time-references/src/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface TimeReference { - emoji: string; - text: string; -} - -export function getTimeReference(hours: number): TimeReference { - if (hours < 1) { - return { emoji: "⏱️", text: "A quick meditation" }; - } - if (hours < 4) { - return { emoji: "🎬", text: "A movie marathon" }; - } - if (hours < 8) { - return { emoji: "✈️", text: "A cross-country flight" }; - } - if (hours < 12) { - return { emoji: "🌙", text: "A full night's sleep" }; - } - if (hours < 16) { - return { emoji: "🏔️", text: "A day hike" }; - } - if (hours < 24) { - return { emoji: "🌍", text: "A day trip abroad" }; - } - if (hours < 36) { - return { emoji: "🚂", text: "A train across the country" }; - } - if (hours < 48) { - return { emoji: "⛵", text: "A weekend sailing trip" }; - } - return { emoji: "🏕️", text: "A multi-day adventure" }; -} - -export function getEncouragingMessage(hours: number): string { - if (hours < 4) { - return "Great start! Your body is beginning to adjust."; - } - if (hours < 8) { - return "You're doing well. Insulin is dropping."; - } - if (hours < 12) { - return "Halfway through a standard fast. Fat burning is ramping up!"; - } - if (hours < 16) { - return "You've passed 12 hours. Autophagy is beginning."; - } - if (hours < 24) { - return "Deep into your fast. Your body is thriving."; - } - if (hours < 36) { - return "Incredible discipline! Growth hormone is surging."; - } - return "Extraordinary commitment. Your body is deeply healing."; -} diff --git a/vendor/bytelyst/time-references/src/types.ts b/vendor/bytelyst/time-references/src/types.ts deleted file mode 100644 index 3295480..0000000 --- a/vendor/bytelyst/time-references/src/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Types for @bytelyst/time-references. - * Pure client-side TS — no backend dependency. - */ - -export interface TimeReference { - text: string; - emoji: string; - category: 'media' | 'activity' | 'travel' | 'nature'; -} - -export interface TimeRangeEntry { - minHours: number; - maxHours: number; - references: TimeReference[]; -} diff --git a/vendor/bytelyst/time-references/tsconfig.json b/vendor/bytelyst/time-references/tsconfig.json deleted file mode 100644 index 8c5e8c2..0000000 --- a/vendor/bytelyst/time-references/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/vendor/bytelyst/ui/.storybook/main.ts b/vendor/bytelyst/ui/.storybook/main.ts deleted file mode 100644 index a01fb35..0000000 --- a/vendor/bytelyst/ui/.storybook/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { StorybookConfig } from '@storybook/react-vite'; - -const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'], - framework: { - name: '@storybook/react-vite', - options: {}, - }, -}; - -export default config; diff --git a/vendor/bytelyst/ui/.storybook/preview.ts b/vendor/bytelyst/ui/.storybook/preview.ts deleted file mode 100644 index f7c4aa2..0000000 --- a/vendor/bytelyst/ui/.storybook/preview.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Preview } from '@storybook/react'; - -const preview: Preview = { - parameters: { - backgrounds: { - default: 'dark', - values: [ - { name: 'dark', value: '#06070A' }, - { name: 'elevated', value: '#0E1118' }, - { name: 'light', value: '#F8F9FC' }, - ], - }, - }, -}; - -export default preview; diff --git a/vendor/bytelyst/ui/README.md b/vendor/bytelyst/ui/README.md deleted file mode 100644 index 2437fda..0000000 --- a/vendor/bytelyst/ui/README.md +++ /dev/null @@ -1,239 +0,0 @@ -# @bytelyst/ui - -Shared component library for the ByteLyst ecosystem. Built with Radix UI primitives, Lucide icons, and CSS custom properties from `@bytelyst/design-tokens`. - -## Install - -```bash -pnpm add @bytelyst/ui -``` - -Peer dependencies: `react`, `react-dom`. - -## Components (15) - -### Button - -5 variants (`primary`, `secondary`, `ghost`, `destructive`, `outline`), 3 sizes, loading state with spinner. - -```tsx -import { Button } from '@bytelyst/ui'; - - - - -``` - -### Input - -Text input with error/success states. Supports `aria-invalid` automatically when `error` is truthy. - -```tsx -import { Input } from '@bytelyst/ui'; - - - -``` - -### Textarea - -Auto-resize textarea with error states. - -```tsx -import { Textarea } from '@bytelyst/ui'; - -