feat: migrate web to Gitea registry, add /api/devops/info, fix role drift

Backend:
- Fix role drift: /api/me/profile now returns JWT role authoritatively (was reading
  drifting role from trading_users). PATCH strips client-supplied role.
- Add /api/devops/info endpoint backed by @bytelyst/devops/server.
- Dockerfile: bake BYTELYST_COMMIT_SHA / BYTELYST_BUILT_AT / etc. as build args.

Web:
- Migrate from vendor/ + .pnpmfile.cjs to Gitea npm registry (consistency with backend).
- Replace file: refs in web/package.json with semver ranges resolved from Gitea.
- Drop vendor/bytelyst/* tree and .pnpmfile.cjs.
- Add DevOpsTab in Settings using @bytelyst/devops/ui (tabbed: Build/Runtime/Config/Deps/Raw).
- Vite alias: restrict @bytelyst/* catch-all to single-segment names so subpath
  imports (@bytelyst/devops/ui) resolve via package exports map.
- Bake BYTELYST_* metadata into the bundle as VITE_BYTELYST_* env.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
root 2026-05-10 04:57:09 +00:00
parent b1667471c9
commit 74400fda70
687 changed files with 206 additions and 71856 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
.git
mobile
ios
android
web/node_modules
web/dist
web/.next

View File

@ -1 +1 @@
@bytelyst:registry=http://gitea.bytelyst.com:3300/api/packages/bytelyst/npm/ @bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/

View File

@ -1,30 +1,45 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app/backend WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
# Use Gitea npm registry for @bytelyst/* packages # Use Gitea npm registry for @bytelyst/* packages
COPY .npmrc.docker ./.npmrc
COPY backend/package.json ./package.json COPY backend/package.json ./package.json
RUN --mount=type=secret,id=gitea_npm_token \ RUN --mount=type=secret,id=gitea_npm_token \
TOKEN=$(cat /run/secrets/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 pnpm install --ignore-scripts --lockfile=false
COPY backend/tsconfig.json ./tsconfig.json COPY backend/tsconfig.json ./backend/tsconfig.json
COPY backend/src/ ./src/ COPY backend/src/ ./backend/src/
COPY shared/ ../shared/ COPY shared/ ./shared/
RUN pnpm run build RUN cd /app/backend && ../node_modules/.bin/tsc -p tsconfig.json
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app/backend WORKDIR /app/backend
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=builder /app/backend/node_modules ./node_modules # Build metadata baked at image build time (consumed by @bytelyst/devops)
COPY --from=builder /app/backend/package.json ./package.json 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 --from=builder /app/backend/dist ./dist
COPY shared/ ../shared/ COPY --from=builder /app/shared/ ../shared/
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node

View File

@ -75,7 +75,9 @@
"jose": "^6.1.2", "jose": "^6.1.2",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"winston": "^3.19.0" "winston": "^3.19.0",
"@bytelyst/telemetry-client": "*",
"@bytelyst/devops": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.3", "@types/node": "^25.0.3",

View File

@ -3,6 +3,7 @@ import { createServer } from 'http';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import cors from 'cors'; import cors from 'cors';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { collectDevopsInfo } from '@bytelyst/devops/server';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@ -2364,6 +2365,20 @@ export class ApiServer {
res.json(flags); 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) => { this.app.get('/api/me/profile', this.requireAuth, async (req, res) => {
const authReq = req as AuthenticatedRequest; const authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId; const authUserId = authReq.authUserId;
@ -2381,6 +2396,11 @@ export class ApiServer {
trade_enable: true, 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 }); res.json({ profile });
}); });
@ -2394,13 +2414,19 @@ export class ApiServer {
const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean);
try { 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<string, unknown>;
const profile = await saveCurrentUserProfile(authUserId, patchBody, {
email: authReq.authEmail, email: authReq.authEmail,
role: authReq.authRole, role: authReq.authRole,
first_name: displayNameParts[0] || '', first_name: displayNameParts[0] || '',
last_name: displayNameParts.slice(1).join(' '), last_name: displayNameParts.slice(1).join(' '),
trade_enable: true, trade_enable: true,
}); });
// JWT role is authoritative.
if (authReq.authRole) {
profile.role = authReq.authRole;
}
res.json({ profile }); res.json({ profile });
} catch (error: any) { } catch (error: any) {
res.status(400).json({ error: `Failed to update profile: ${error.message}` }); res.status(400).json({ error: `Failed to update profile: ${error.message}` });

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it('merges defaultHeaders', async () => {
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
const api = createApiClient({
baseUrl: '/api',
defaultHeaders: { 'X-Custom': 'value' },
});
await api.fetch('/test');
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Custom']).toBe('value');
expect(headers['Content-Type']).toBe('application/json');
});
});
describe('safeFetch', () => {
it('returns { data, error: null } on success', async () => {
mockFetch.mockResolvedValue(jsonResponse({ id: '1' }));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch<{ id: string }>('/items/1');
expect(result.data).toEqual({ id: '1' });
expect(result.error).toBeNull();
});
it('returns { data: null, error } on HTTP error', async () => {
mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch('/secret');
expect(result.data).toBeNull();
expect(result.error).toBe('Forbidden');
});
it('returns { data: null, error } on network error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const api = createApiClient({ baseUrl: '/api' });
const result = await api.safeFetch('/unreachable');
expect(result.data).toBeNull();
expect(result.error).toBe('API unavailable');
});
});
});

View File

@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Create an API client with a base URL and optional auth token.
*
* @example
* ```ts
* const api = createApiClient({
* baseUrl: "/api",
* getToken: () => localStorage.getItem("access_token"),
* });
*
* // Throws on error
* const users = await api.fetch<User[]>("/users");
*
* // Never throws
* const { data, error } = await api.safeFetch<User[]>("/users");
* ```
*/
export function createApiClient(config: ApiClientConfig): ApiClient {
const {
baseUrl,
getToken,
defaultHeaders,
timeoutMs = 10_000,
retries = 2,
retryDelayMs = 500,
} = config;
function buildHeaders(options?: RequestInit): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id':
typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
...defaultHeaders,
};
if (getToken) {
const token = getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
if (options?.headers) {
const extra: Record<string, string> = {};
if (options.headers instanceof Headers) {
options.headers.forEach((value, key) => {
extra[key] = value;
});
} else if (Array.isArray(options.headers)) {
Object.assign(extra, Object.fromEntries(options.headers));
} else {
Object.assign(extra, options.headers);
}
Object.assign(headers, extra);
}
return headers;
}
function buildInit(options?: RequestInit): RequestInit {
const init: RequestInit = {
...options,
headers: buildHeaders(options),
};
// AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0)
if (timeoutMs > 0 && !options?.signal) {
const controller = new AbortController();
init.signal = controller.signal;
setTimeout(() => controller.abort(), timeoutMs);
}
return init;
}
function isRetryable(method: string | undefined): boolean {
return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase());
}
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
const maxAttempts = isRetryable(init.method) ? retries + 1 : 1;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await globalThis.fetch(url, init);
// Only retry on 502/503/504 for idempotent methods
if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) {
await sleep(retryDelayMs * 2 ** attempt);
continue;
}
return res;
} catch (err) {
lastError = err;
if (attempt < maxAttempts - 1) {
await sleep(retryDelayMs * 2 ** attempt);
continue;
}
}
}
throw lastError;
}
return {
async fetch<T>(path: string, options?: RequestInit): Promise<T> {
const init = buildInit(options);
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
},
async safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
try {
const init = buildInit(options);
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || `HTTP ${res.status}` };
}
const data = (await res.json()) as T;
return { data, error: null };
} catch {
return { data: null, error: 'API unavailable' };
}
},
};
}

View File

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

View File

@ -1,28 +0,0 @@
export interface ApiClientConfig {
baseUrl: string;
getToken?: () => string | null;
defaultHeaders?: Record<string, string>;
/** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */
timeoutMs?: number;
/** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */
retries?: number;
/** Base delay in ms for exponential backoff between retries. Default: 500. */
retryDelayMs?: number;
}
export interface ApiResult<T> {
data: T | null;
error: string | null;
}
export interface ApiClient {
/**
* Fetch that throws on error use when caller handles errors.
*/
fetch<T>(path: string, options?: RequestInit): Promise<T>;
/**
* Safe fetch that never throws returns { data, error } tuple.
*/
safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>>;
}

View File

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

View File

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

View File

@ -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<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
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<typeof createMockStorage>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][1].body);
expect(body.email).toBe('user@test.com');
expect(body.productId).toBe('chronomind');
});
});
});

View File

@ -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<string, string> } {
const store = new Map<string, string>();
return {
store,
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
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<typeof createMockStorage>;
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
});

View File

@ -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<T>(
path: string,
method: string,
body?: unknown,
opts?: { skipAuth?: boolean }
): Promise<T> {
const headers: Record<string, string> = {
'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<string, string>).message ||
(data as Record<string, string>).error ||
`HTTP ${res.status}`
);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
} finally {
clearTimeout(timer);
}
}
// ── Singleton refresh guard ─────────────────────
let _refreshPromise: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
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<AuthResult | MfaRequiredResult> {
const result = await request<AuthResult | MfaRequiredResult>('/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<AuthResult> {
const result = await request<AuthResult>('/auth/register', 'POST', {
email,
password,
displayName,
productId,
});
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function getMe(): Promise<AuthUser> {
return request<AuthUser>('/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<AuthResult | MfaRequiredResult> {
const result = await request<AuthResult | MfaRequiredResult>(
`/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<AuthResult | MfaRequiredResult> {
return loginWithOAuth('google', idToken);
}
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
return loginWithOAuth('microsoft', idToken);
}
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
return loginWithOAuth('apple', idToken);
}
// ── Provider management (Phase 1C) ─────────────────
async function getProviders(): Promise<AuthProvider[]> {
const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET');
return data.providers;
}
async function linkProvider(provider: string, idToken: string): Promise<void> {
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
}
async function unlinkProvider(provider: string): Promise<void> {
await request<void>(`/auth/providers/${provider}`, 'DELETE');
}
// ── MFA (Phase 2D) ─────────────────────────────────
async function verifyMfa(
challengeToken: string,
code: string,
method: 'totp' | 'recovery'
): Promise<AuthResult> {
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
challengeToken,
code,
method,
});
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function setupTotp(): Promise<TotpSetupResult> {
return request<TotpSetupResult>('/auth/mfa/setup', 'POST');
}
async function verifyTotpSetup(code: string): Promise<void> {
await request<void>('/auth/mfa/verify-setup', 'POST', { code });
}
async function disableMfa(code: string): Promise<void> {
await request<void>('/auth/mfa/disable', 'POST', { code });
}
async function getMfaStatus(): Promise<MfaStatus> {
return request<MfaStatus>('/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<unknown> {
return request<unknown>('/auth/passkeys/register/options', 'POST');
}
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
await request<void>('/auth/passkeys/register/verify', 'POST', response);
}
async function getPasskeyAuthOptions(): Promise<unknown> {
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
skipAuth: true,
});
}
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
const result = await request<AuthResult>(
'/auth/passkeys/authenticate/verify',
'POST',
response,
{ skipAuth: true }
);
setTokens(result.accessToken, result.refreshToken);
return result;
}
async function listPasskeys(): Promise<Passkey[]> {
const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET');
return data.passkeys;
}
async function deletePasskey(id: string): Promise<void> {
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
}
// ── Devices (Phase 3) ──────────────────────────────
async function listDevices(): Promise<Device[]> {
const data = await request<{ devices: Device[] }>('/auth/devices', 'GET');
return data.devices;
}
async function trustDevice(
fingerprint: string,
trustLevel: 'trusted' | 'remembered',
deviceInfo?: Record<string, string>
): Promise<void> {
await request<void>('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo });
}
async function revokeDevice(fingerprint: string): Promise<void> {
await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
}
async function revokeAllDevices(): Promise<void> {
await request<void>('/auth/devices/revoke-all', 'POST');
}
// ── Admin security (Phase 5B) ──────────────────────
async function getSecurityOverview(): Promise<SecurityOverview> {
return request<SecurityOverview>('/auth/security/overview', 'GET');
}
async function unlockUser(userId: string): Promise<void> {
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
}
async function exportAuthData(): Promise<unknown> {
return request<unknown>('/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<LoginEventInfo[]> {
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<LoginEventInfo[]> {
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<Device[]> {
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,
};
}

View File

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

View File

@ -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<string, string | undefined>;
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<string, number>;
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<AuthResult | MfaRequiredResult>;
register(email: string, password: string, displayName: string): Promise<AuthResult>;
getMe(): Promise<AuthUser>;
refreshAccessToken(): Promise<boolean>;
// ── OAuth / Social login ────────────────────────
loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult>;
loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult>;
loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult>;
// ── Provider management ─────────────────────────
getProviders(): Promise<AuthProvider[]>;
linkProvider(provider: string, idToken: string): Promise<void>;
unlinkProvider(provider: string): Promise<void>;
// ── MFA ─────────────────────────────────────────
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
setupTotp(): Promise<TotpSetupResult>;
verifyTotpSetup(code: string): Promise<void>;
disableMfa(code: string): Promise<void>;
getMfaStatus(): Promise<MfaStatus>;
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
// ── Passkeys (WebAuthn) ─────────────────────────
getPasskeyRegisterOptions(): Promise<unknown>;
verifyPasskeyRegistration(response: unknown): Promise<void>;
getPasskeyAuthOptions(): Promise<unknown>;
verifyPasskeyAuth(response: unknown): Promise<AuthResult>;
listPasskeys(): Promise<Passkey[]>;
deletePasskey(id: string): Promise<void>;
// ── Devices ─────────────────────────────────────
listDevices(): Promise<Device[]>;
trustDevice(
fingerprint: string,
trustLevel: 'trusted' | 'remembered',
deviceInfo?: Record<string, string>
): Promise<void>;
revokeDevice(fingerprint: string): Promise<void>;
revokeAllDevices(): Promise<void>;
// ── Step-up auth ────────────────────────────────
stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>;
// ── Login history ───────────────────────────────
getLoginHistory(limit?: number): Promise<LoginEventInfo[]>;
// ── Admin security ──────────────────────────────
getSecurityOverview(): Promise<SecurityOverview>;
unlockUser(userId: string): Promise<void>;
getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise<LoginEventInfo[]>;
getAdminDevices(userId: string): Promise<Device[]>;
exportAuthData(): Promise<unknown>;
// ── 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 }>;
}

View File

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

View File

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

View File

@ -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 (
<div
className={className}
data-testid="bl-auth-page"
style={{
display: 'flex',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
background: 'var(--bl-page-bg, #f5f5f5)',
fontFamily: 'var(--bl-font, system-ui, -apple-system, sans-serif)',
}}
>
<div
style={{
width: '100%',
maxWidth: '400px',
background: 'var(--bl-surface, #fff)',
borderRadius: 'var(--bl-card-radius, 12px)',
boxShadow: 'var(--bl-card-shadow, 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06))',
padding: '32px',
}}
>
{/* Branding */}
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
{logo && (
<div style={{ marginBottom: '12px' }} data-testid="bl-auth-logo">
{typeof logo === 'string' ? (
<img src={logo} alt={productName} style={{ height: '48px' }} />
) : (
logo
)}
</div>
)}
<div
style={{ fontSize: '20px', fontWeight: 700, color: 'var(--bl-text, #111)' }}
data-testid="bl-auth-product-name"
>
{productName}
</div>
<div
style={{
fontSize: '16px',
fontWeight: 600,
color: 'var(--bl-text, #333)',
marginTop: '8px',
}}
data-testid="bl-auth-title"
>
{title}
</div>
{subtitle && (
<div
style={{ fontSize: '14px', color: 'var(--bl-muted, #666)', marginTop: '4px' }}
data-testid="bl-auth-subtitle"
>
{subtitle}
</div>
)}
</div>
{/* Form content */}
{children}
{/* Footer */}
{footer && (
<div
style={{
marginTop: '20px',
paddingTop: '16px',
borderTop: '1px solid var(--bl-border, #eee)',
textAlign: 'center',
fontSize: '13px',
color: 'var(--bl-muted, #999)',
}}
data-testid="bl-auth-footer"
>
{footer}
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-forgot-password-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
Enter your email address and we'll send you a link to reset your password.
</div>
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={isLoading}
data-testid="bl-forgot-email"
style={inputStyle}
/>
{error && (
<div
data-testid="bl-forgot-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
{success && (
<div
data-testid="bl-forgot-success"
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
>
{success}
</div>
)}
<button
type="submit"
disabled={isLoading || email.length === 0}
data-testid="bl-forgot-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading ? 0.6 : 1,
}}
>
{isLoading ? 'Sending...' : 'Send reset link'}
</button>
{onBack && (
<button
type="button"
onClick={onBack}
data-testid="bl-forgot-back"
style={{
padding: '8px',
border: 'none',
background: 'transparent',
color: 'var(--bl-link, #0066ff)',
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
}}
>
Back to sign in
</button>
)}
</form>
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-login-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<input
type="email"
placeholder="Email"
value={email}
onChange={e => 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',
}}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => 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 && (
<div
data-testid="bl-login-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
data-testid="bl-login-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading ? 0.6 : 1,
}}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{providers && providers.length > 0 && onSocialLogin && (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '16px 0',
color: 'var(--bl-muted, #999)',
fontSize: '13px',
}}
>
<hr
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
/>
or
<hr
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
/>
</div>
<SocialButtons providers={providers} onSelect={onSocialLogin} disabled={isLoading} />
</>
)}
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-mfa-challenge">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
Enter your authentication code
</div>
{methods && methods.length > 0 && (
<div
data-testid="bl-mfa-methods"
style={{ fontSize: '12px', color: 'var(--bl-muted, #999)' }}
>
Available methods: {methods.join(', ')}
</div>
)}
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
value={code}
onChange={e => 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 && (
<div
data-testid="bl-mfa-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={isLoading || code.length < 6}
data-testid="bl-mfa-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading || code.length < 6 ? 0.6 : 1,
}}
>
{isLoading ? 'Verifying...' : 'Verify'}
</button>
{onUseRecovery && (
<button
type="button"
onClick={onUseRecovery}
disabled={isLoading}
data-testid="bl-mfa-recovery"
style={{
padding: '8px',
border: 'none',
background: 'transparent',
color: 'var(--bl-link, #0066ff)',
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
}}
>
Use a recovery code
</button>
)}
</form>
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-onboarding-shell">
{/* Progress bar */}
<div
style={{
height: '4px',
borderRadius: '2px',
background: 'var(--bl-border, #e5e7eb)',
marginBottom: '24px',
overflow: 'hidden',
}}
>
<div
data-testid="bl-onboarding-progress"
style={{
height: '100%',
width: `${progress}%`,
background: 'var(--bl-primary, #0066ff)',
transition: 'width 0.3s ease',
borderRadius: '2px',
}}
/>
</div>
{/* Step indicator */}
<div
data-testid="bl-onboarding-steps"
style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
{steps.map((step, i) => (
<div
key={step.key}
data-testid={`bl-onboarding-step-${step.key}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
color:
i === currentStep
? 'var(--bl-primary, #0066ff)'
: i < currentStep
? 'var(--bl-success, #22c55e)'
: 'var(--bl-muted, #999)',
fontWeight: i === currentStep ? 600 : 400,
}}
>
<span
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 600,
background:
i <= currentStep ? 'var(--bl-primary, #0066ff)' : 'var(--bl-border, #e5e7eb)',
color: i <= currentStep ? '#fff' : 'var(--bl-muted, #999)',
}}
>
{i < currentStep ? '✓' : i + 1}
</span>
{step.label}
</div>
))}
</div>
{/* Step content */}
<div data-testid="bl-onboarding-content" style={{ marginBottom: '24px' }}>
{children}
</div>
{/* Navigation */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '12px',
}}
>
<button
type="button"
onClick={onBack}
disabled={isFirst}
data-testid="bl-onboarding-back"
style={{
padding: '10px 20px',
border: '1px solid var(--bl-border, #ccc)',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-surface, #fff)',
color: 'var(--bl-text, #333)',
cursor: isFirst ? 'not-allowed' : 'pointer',
fontSize: '14px',
opacity: isFirst ? 0.4 : 1,
}}
>
Back
</button>
<button
type="button"
onClick={isLast ? onComplete : onNext}
data-testid={isLast ? 'bl-onboarding-complete' : 'bl-onboarding-next'}
style={{
padding: '10px 20px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
{isLast ? 'Complete' : 'Next'}
</button>
</div>
</div>
);
}

View File

@ -1,67 +0,0 @@
import { useMemo } from 'react';
import type { PasswordStrength } from './types.js';
interface PasswordStrengthBarProps {
password: string;
className?: string;
}
const STRENGTH_CONFIG: Record<PasswordStrength, { color: string; label: string }> = {
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 (
<div className={className} data-testid="bl-password-strength">
<div
style={{
height: '4px',
borderRadius: '2px',
background: 'var(--bl-border, #e5e7eb)',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${widthPercent}%`,
background: config.color,
transition: 'width 0.2s, background 0.2s',
borderRadius: '2px',
}}
data-testid="bl-password-strength-fill"
/>
</div>
<div
style={{ fontSize: '12px', color: config.color, marginTop: '4px' }}
data-testid="bl-password-strength-label"
>
{config.label}
</div>
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-register-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
Name
<input
type="text"
placeholder="Your name"
value={name}
onChange={e => setName(e.target.value)}
required
disabled={isLoading}
data-testid="bl-register-name"
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
/>
</label>
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
Email
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={isLoading}
data-testid="bl-register-email"
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
/>
</label>
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
Password
<input
type="password"
placeholder="Min 8 characters"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
data-testid="bl-register-password"
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
/>
</label>
<PasswordStrengthBar password={password} />
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
Confirm password
<input
type="password"
placeholder="Re-enter password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
disabled={isLoading}
data-testid="bl-register-confirm"
style={{
...inputStyle,
marginTop: '4px',
display: 'block',
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
}}
/>
</label>
{passwordMismatch && (
<div
data-testid="bl-register-mismatch"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
>
Passwords do not match
</div>
)}
{termsUrl && (
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '13px',
color: 'var(--bl-text, #333)',
}}
>
<input
type="checkbox"
checked={termsAccepted}
onChange={e => setTermsAccepted(e.target.checked)}
disabled={isLoading}
data-testid="bl-register-terms"
style={{ marginTop: '2px' }}
/>
<span>
I agree to the{' '}
<a
href={termsUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--bl-link, #0066ff)' }}
>
Terms of Service
</a>
{privacyUrl && (
<>
{' '}
and{' '}
<a
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--bl-link, #0066ff)' }}
>
Privacy Policy
</a>
</>
)}
</span>
</label>
)}
{error && (
<div
data-testid="bl-register-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
data-testid="bl-register-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: !canSubmit ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: !canSubmit ? 0.6 : 1,
}}
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
{onSwitchToLogin && (
<div style={{ textAlign: 'center', fontSize: '13px', color: 'var(--bl-muted, #999)' }}>
Already have an account?{' '}
<button
type="button"
onClick={onSwitchToLogin}
data-testid="bl-register-switch-login"
style={{
border: 'none',
background: 'transparent',
color: 'var(--bl-link, #0066ff)',
cursor: 'pointer',
textDecoration: 'underline',
fontSize: '13px',
padding: 0,
}}
>
Sign in
</button>
</div>
)}
</form>
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-reset-password-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
Enter your new password.
</div>
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
New password
<input
type="password"
placeholder="Min 8 characters"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
data-testid="bl-reset-password"
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
/>
</label>
<PasswordStrengthBar password={password} />
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
Confirm password
<input
type="password"
placeholder="Re-enter password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
disabled={isLoading}
data-testid="bl-reset-confirm"
style={{
...inputStyle,
marginTop: '4px',
display: 'block',
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
}}
/>
</label>
{passwordMismatch && (
<div
data-testid="bl-reset-mismatch"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
>
Passwords do not match
</div>
)}
{error && (
<div
data-testid="bl-reset-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
{success && (
<div
data-testid="bl-reset-success"
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
>
{success}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
data-testid="bl-reset-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: !canSubmit ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: !canSubmit ? 0.6 : 1,
}}
>
{isLoading ? 'Updating...' : 'Update password'}
</button>
</form>
</div>
);
}

View File

@ -1,48 +0,0 @@
import type { SocialButtonsProps, SocialProvider } from './types.js';
const PROVIDER_LABELS: Record<SocialProvider, string> = {
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 (
<div
className={className}
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
data-testid="bl-social-buttons"
>
{providers.map(provider => (
<button
key={provider}
type="button"
onClick={() => onSelect(provider)}
disabled={disabled}
data-testid={`bl-social-${provider}`}
style={{
padding: '10px 16px',
border: '1px solid var(--bl-border, #ccc)',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-surface, #fff)',
color: 'var(--bl-text, #333)',
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '14px',
opacity: disabled ? 0.6 : 1,
}}
>
Continue with {PROVIDER_LABELS[provider]}
</button>
))}
</div>
);
}

View File

@ -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 (
<div className={className} data-testid="bl-verify-email-form">
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
>
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
Enter the 6-digit code sent to {email ? <strong>{email}</strong> : 'your email'}.
</div>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
value={code}
onChange={e => 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 && (
<div
data-testid="bl-verify-error"
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
>
{error}
</div>
)}
{success && (
<div
data-testid="bl-verify-success"
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
>
{success}
</div>
)}
<button
type="submit"
disabled={isLoading || code.length < 6}
data-testid="bl-verify-submit"
style={{
padding: '10px 16px',
border: 'none',
borderRadius: 'var(--bl-radius, 6px)',
background: 'var(--bl-primary, #0066ff)',
color: '#fff',
cursor: isLoading || code.length < 6 ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 600,
opacity: isLoading || code.length < 6 ? 0.6 : 1,
}}
>
{isLoading ? 'Verifying...' : 'Verify email'}
</button>
{onResend && (
<button
type="button"
onClick={onResend}
disabled={isLoading}
data-testid="bl-verify-resend"
style={{
padding: '8px',
border: 'none',
background: 'transparent',
color: 'var(--bl-link, #0066ff)',
cursor: 'pointer',
fontSize: '13px',
textDecoration: 'underline',
}}
>
Resend code
</button>
)}
</form>
</div>
);
}

View File

@ -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(<SocialButtons providers={['google', 'microsoft', 'apple']} onSelect={onSelect} />);
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(<SocialButtons providers={['google']} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('bl-social-google'));
expect(onSelect).toHaveBeenCalledWith('google');
});
it('disables buttons when disabled prop is true', () => {
const onSelect = vi.fn();
render(<SocialButtons providers={['google']} onSelect={onSelect} disabled />);
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(<LoginForm onSubmit={onSubmit} />);
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(<LoginForm onSubmit={onSubmit} />);
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(<LoginForm onSubmit={onSubmit} error="Invalid credentials" />);
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(
<LoginForm
onSubmit={onSubmit}
providers={['google', 'apple']}
onSocialLogin={onSocialLogin}
/>
);
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(<LoginForm onSubmit={onSubmit} isLoading />);
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(<MfaChallenge onSubmit={onSubmit} />);
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(<MfaChallenge onSubmit={onSubmit} />);
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(<MfaChallenge onSubmit={onSubmit} methods={['totp', 'recovery']} />);
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(<MfaChallenge onSubmit={onSubmit} onUseRecovery={onUseRecovery} />);
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(<MfaChallenge onSubmit={onSubmit} error="Invalid code" />);
expect(screen.getByTestId('bl-mfa-error')).toBeDefined();
expect(screen.getByText('Invalid code')).toBeDefined();
});
});
});

View File

@ -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(<RegisterForm onSubmit={vi.fn()} />);
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(<RegisterForm onSubmit={onSubmit} />);
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(<RegisterForm onSubmit={vi.fn()} />);
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(<RegisterForm onSubmit={vi.fn()} error="Email already taken" />);
expect(screen.getByTestId('bl-register-error')).toBeDefined();
expect(screen.getByText('Email already taken')).toBeDefined();
});
it('shows terms checkbox when termsUrl provided', () => {
render(<RegisterForm onSubmit={vi.fn()} termsUrl="https://example.com/terms" />);
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(<RegisterForm onSubmit={vi.fn()} onSwitchToLogin={onSwitch} />);
const link = screen.getByTestId('bl-register-switch-login');
fireEvent.click(link);
expect(onSwitch).toHaveBeenCalledOnce();
});
it('shows loading state', () => {
render(<RegisterForm onSubmit={vi.fn()} isLoading />);
expect(screen.getByText('Creating account...')).toBeDefined();
});
it('shows password strength bar when typing', () => {
render(<RegisterForm onSubmit={vi.fn()} />);
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(<ForgotPasswordForm onSubmit={vi.fn()} />);
expect(screen.getByTestId('bl-forgot-email')).toBeDefined();
expect(screen.getByTestId('bl-forgot-submit')).toBeDefined();
});
it('calls onSubmit with email', () => {
const onSubmit = vi.fn();
render(<ForgotPasswordForm onSubmit={onSubmit} />);
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(<ForgotPasswordForm onSubmit={vi.fn()} error="Email not found" />);
expect(screen.getByTestId('bl-forgot-error')).toBeDefined();
});
it('displays success message', () => {
render(<ForgotPasswordForm onSubmit={vi.fn()} success="Check your email" />);
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(<ForgotPasswordForm onSubmit={vi.fn()} onBack={onBack} />);
fireEvent.click(screen.getByTestId('bl-forgot-back'));
expect(onBack).toHaveBeenCalledOnce();
});
it('shows loading state', () => {
render(<ForgotPasswordForm onSubmit={vi.fn()} isLoading />);
expect(screen.getByText('Sending...')).toBeDefined();
});
});
describe('ResetPasswordForm', () => {
beforeEach(() => cleanup());
it('renders password fields and submit', () => {
render(<ResetPasswordForm onSubmit={vi.fn()} />);
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(<ResetPasswordForm onSubmit={onSubmit} />);
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(<ResetPasswordForm onSubmit={vi.fn()} />);
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(<ResetPasswordForm onSubmit={vi.fn()} error="Token expired" />);
expect(screen.getByTestId('bl-reset-error')).toBeDefined();
rerender(<ResetPasswordForm onSubmit={vi.fn()} success="Password updated" />);
expect(screen.getByTestId('bl-reset-success')).toBeDefined();
});
it('shows password strength bar', () => {
render(<ResetPasswordForm onSubmit={vi.fn()} />);
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(<VerifyEmailForm onSubmit={vi.fn()} />);
expect(screen.getByTestId('bl-verify-code')).toBeDefined();
expect(screen.getByTestId('bl-verify-submit')).toBeDefined();
});
it('calls onSubmit with code', () => {
const onSubmit = vi.fn();
render(<VerifyEmailForm onSubmit={onSubmit} />);
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(<VerifyEmailForm onSubmit={vi.fn()} email="test@example.com" />);
expect(screen.getByText('test@example.com')).toBeDefined();
});
it('renders resend button', () => {
const onResend = vi.fn();
render(<VerifyEmailForm onSubmit={vi.fn()} onResend={onResend} />);
fireEvent.click(screen.getByTestId('bl-verify-resend'));
expect(onResend).toHaveBeenCalledOnce();
});
it('displays error and success messages', () => {
const { rerender } = render(<VerifyEmailForm onSubmit={vi.fn()} error="Invalid code" />);
expect(screen.getByTestId('bl-verify-error')).toBeDefined();
rerender(<VerifyEmailForm onSubmit={vi.fn()} success="Code resent" />);
expect(screen.getByTestId('bl-verify-success')).toBeDefined();
});
it('strips non-numeric characters', () => {
render(<VerifyEmailForm onSubmit={vi.fn()} />);
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(
<OnboardingShell
steps={steps}
currentStep={0}
onNext={vi.fn()}
onBack={vi.fn()}
onComplete={vi.fn()}
>
<div>Step 1 content</div>
</OnboardingShell>
);
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(
<OnboardingShell
steps={steps}
currentStep={0}
onNext={vi.fn()}
onBack={vi.fn()}
onComplete={vi.fn()}
>
<div />
</OnboardingShell>
);
const back = screen.getByTestId('bl-onboarding-back');
expect(back.getAttribute('disabled')).toBe('');
});
it('calls onNext on middle step', () => {
const onNext = vi.fn();
render(
<OnboardingShell
steps={steps}
currentStep={1}
onNext={onNext}
onBack={vi.fn()}
onComplete={vi.fn()}
>
<div />
</OnboardingShell>
);
fireEvent.click(screen.getByTestId('bl-onboarding-next'));
expect(onNext).toHaveBeenCalledOnce();
});
it('shows Complete on last step and calls onComplete', () => {
const onComplete = vi.fn();
render(
<OnboardingShell
steps={steps}
currentStep={2}
onNext={vi.fn()}
onBack={vi.fn()}
onComplete={onComplete}
>
<div />
</OnboardingShell>
);
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(
<OnboardingShell
steps={steps}
currentStep={1}
onNext={vi.fn()}
onBack={onBack}
onComplete={vi.fn()}
>
<div />
</OnboardingShell>
);
fireEvent.click(screen.getByTestId('bl-onboarding-back'));
expect(onBack).toHaveBeenCalledOnce();
});
});
describe('AuthPageLayout', () => {
beforeEach(() => cleanup());
it('renders product name and title', () => {
render(
<AuthPageLayout productName="TestApp" title="Sign In">
<div>Form content</div>
</AuthPageLayout>
);
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(
<AuthPageLayout productName="TestApp" title="Sign In" subtitle="Welcome back">
<div />
</AuthPageLayout>
);
expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back');
});
it('renders logo as element', () => {
render(
<AuthPageLayout
productName="TestApp"
title="Sign In"
logo={<span data-testid="custom-logo">Logo</span>}
>
<div />
</AuthPageLayout>
);
expect(screen.getByTestId('custom-logo')).toBeDefined();
});
it('renders footer', () => {
render(
<AuthPageLayout productName="TestApp" title="Sign In" footer={<span>Footer text</span>}>
<div />
</AuthPageLayout>
);
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(<PasswordStrengthBar password="" />);
expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull();
});
it('shows Weak for short password', () => {
render(<PasswordStrengthBar password="ab" />);
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak');
});
it('shows Strong for complex password', () => {
render(<PasswordStrengthBar password="MyStr0ng!Pass" />);
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');
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof createRemoteJWKSet> | null = null;
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj;
if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing');
_rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey;
return _rsaPrivateKeyObj;
}
async function getRsaPublicKey(): Promise<JoseCryptoKey> {
if (_rsaPublicKeyObj) return _rsaPublicKeyObj;
if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification');
_rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey;
return _rsaPublicKeyObj;
}
function getJwksKeySet(): ReturnType<typeof createRemoteJWKSet> {
if (_jwksKeySet) return _jwksKeySet;
if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification');
_jwksKeySet = createRemoteJWKSet(new URL(jwksUrl));
return _jwksKeySet;
}
// ── Signing ───────────────────────────────────────
async function sign(claims: Record<string, unknown>, expiry: string): Promise<string> {
if (algorithm === 'RS256') {
const key = await getRsaPrivateKey();
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime(expiry)
.setIssuer(issuer)
.sign(key);
}
return new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(expiry)
.setIssuer(issuer)
.sign(getHmacSecret());
}
// ── Verification (dual: RS256 first, HS256 fallback) ──
async function verifyWithRS256(token: string): Promise<TokenPayload | null> {
try {
if (jwksUrl) {
const keySet = getJwksKeySet();
const { payload } = await jwtVerify(token, keySet, { issuer });
return payload as unknown as TokenPayload;
}
if (rsaPublicKey) {
const key = await getRsaPublicKey();
const { payload } = await jwtVerify(token, key, { issuer });
return payload as unknown as TokenPayload;
}
return null;
} catch {
return null;
}
}
async function verifyWithHS256(token: string): Promise<TokenPayload | null> {
try {
const secret = getHmacSecret();
const { payload } = await jwtVerify(token, secret, { issuer });
return payload as unknown as TokenPayload;
} catch {
return null;
}
}
return {
async createAccessToken(payload) {
return sign(
{
...payload,
productId: payload.productId || issuer,
type: 'access',
},
accessTokenExpiry
);
},
async createRefreshToken(payload) {
return sign(
{
sub: payload.sub,
productId: payload.productId || issuer,
type: 'refresh',
},
refreshTokenExpiry
);
},
async verifyToken(token: string) {
// Dual verification: try RS256 first (if configured), then HS256 fallback
if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) {
const rs256Result = await verifyWithRS256(token);
if (rs256Result) return rs256Result;
}
// HS256 fallback (safe during migration; removed after full RS256 rollout)
try {
return await verifyWithHS256(token);
} catch {
return null;
}
},
};
}

View File

@ -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<AuthPayload> {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
throw new UnauthorizedError();
}
const token = auth.slice(7);
try {
const { payload } = await jwtVerify(token, getSecret());
const p = payload as unknown as AuthPayload;
if (p.type !== 'access') throw new Error('Not an access token');
return p;
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
}
/**
* Require specific roles. Extracts auth first, then checks role.
*
* @throws Error with statusCode 403 if role doesn't match
*/
export async function requireRole(
req: { headers: { authorization?: string } },
...roles: string[]
): Promise<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw new ForbiddenError('Insufficient permissions');
}
return payload;
}

View File

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

View File

@ -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<TUser>(
authHeader: string | null,
verifyToken: (token: string) => Promise<TokenPayload | null>,
getUserById: (id: string) => Promise<TUser | null>
): Promise<TUser | null> {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload || payload.type !== 'access') return null;
return getUserById(payload.sub);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof baseBackendConfigSchema>;
/**
* 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<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
): z.infer<z.ZodObject<T>> {
return schema.parse(env);
}

View File

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

View File

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

View File

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

View File

@ -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<string, boolean>;
setFlag(flag: string, value: boolean): void;
}
export interface FlagRegistryOptions {
/** Default flag values. */
defaults: Record<string, boolean>;
/** 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<string, boolean> = new Map(Object.entries(opts.defaults));
return {
isFeatureEnabled(flag: string, _userId?: string): boolean {
return flags.get(flag) ?? false;
},
getAllFlags(): Record<string, boolean> {
return Object.fromEntries(flags);
},
setFlag(flag: string, value: boolean): void {
flags.set(flag, value);
},
};
}

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>;
timestamp?: string;
}
export interface TelemetryBuffer {
trackEvent(event: string, userId?: string, properties?: Record<string, unknown>): 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<string, unknown>): 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;
},
};
}

View File

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

View File

@ -1 +0,0 @@
*.tgz

View File

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

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBeUndefined();
});
});

View File

@ -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<PlanConfig[]>;
/** Get a specific plan by name. */
getPlan(planName: string): Promise<PlanConfig>;
/** Get current user's subscription. Returns null if no subscription. */
getSubscription(): Promise<Subscription | null>;
/** Change to a different plan (creates or updates subscription). */
changePlan(plan: PlanTier): Promise<Subscription>;
/** Cancel subscription at period end. */
cancelSubscription(): Promise<Subscription>;
/** Resume a cancelled subscription (undo cancel-at-period-end). */
resumeSubscription(): Promise<Subscription>;
/** List payment history. */
listPayments(): Promise<Payment[]>;
/** Get usage summary for current billing period. */
getUsage(): Promise<UsageSummary>;
}
// ── 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<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
'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<string, string>).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<PlanConfig>('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<Subscription | null>('GET', '/subscriptions/me');
},
async changePlan(plan: PlanTier) {
return request<Subscription>('POST', '/subscriptions/me/change-plan', { plan });
},
async cancelSubscription() {
return request<Subscription>('POST', '/subscriptions/me/cancel');
},
async resumeSubscription() {
return request<Subscription>('POST', '/subscriptions/me/resume');
},
async listPayments() {
const result = await request<{ payments: Payment[] }>('GET', '/payments/me');
return result.payments;
},
async getUsage() {
return request<UsageSummary>('GET', '/subscriptions/me/usage');
},
};
}

View File

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

View File

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

View File

@ -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<Parameters<typeof createBlobClient>[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');
});
});
});

View File

@ -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<string, string>;
}
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 `<productId>/<userId>/<timestamp>-<random>`. */
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<SasUrlResponse>;
/** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */
upload(
container: string,
data: Blob | ArrayBuffer | Uint8Array | string,
options: UploadOptions
): Promise<UploadResult>;
/** Download a blob via SAS URL. Returns the Response for streaming. */
download(container: string, blobName: string): Promise<Response>;
/** List blobs in a container. */
list(
container: string,
options?: { prefix?: string; limit?: number }
): Promise<ListBlobsResponse>;
/** Get blob metadata/info. */
info(container: string, blobName: string): Promise<BlobInfo>;
}
// ── 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<string, string> {
const headers: Record<string, string> = {
'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<T>(method: string, path: string, body?: unknown): Promise<T> {
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<string, string>).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<SasUrlResponse> {
return apiRequest<SasUrlResponse>('POST', '/blob/sas', {
container,
blobName,
permissions,
expiresInMinutes,
});
}
async function upload(
container: string,
data: Blob | ArrayBuffer | Uint8Array | string,
options: UploadOptions
): Promise<UploadResult> {
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<Response> {
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<ListBlobsResponse> {
const params = new URLSearchParams({ container });
if (options?.prefix) params.set('prefix', options.prefix);
if (options?.limit) params.set('limit', String(options.limit));
return apiRequest<ListBlobsResponse>('GET', `/blob/list?${params.toString()}`);
}
async function info(container: string, blobName: string): Promise<BlobInfo> {
return apiRequest<BlobInfo>(
'GET',
`/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}`
);
}
return { getSasUrl, upload, download, list, info };
}

View File

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

View File

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

View File

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

View File

@ -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<StorageProvider> {
return getStorage();
}
/**
* Get a bucket (container) by name.
*/
export async function getBucket(containerName: string): Promise<StorageBucket> {
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<string> {
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();
}

View File

@ -1 +0,0 @@
export * from './blob.js';

View File

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

View File

@ -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<string> | 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 (
<div>
{messages.map(msg => (
<Banner
key={msg.id}
title={msg.title}
body={msg.body}
onDismiss={() => markDismissed(msg.id)}
onClick={() => markRead(msg.id)}
/>
))}
</div>
);
}
```
### Provider Pattern
```typescript
// BroadcastProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client';
const BroadcastContext = createContext<BroadcastContextType | null>(null);
export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) {
const [client] = useState(() => createBroadcastClient(config));
const [messages, setMessages] = useState<InAppMessage[]>([]);
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 (
<BroadcastContext.Provider value={value}>
{children}
</BroadcastContext.Provider>
);
}
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<string, string>;
};
status: 'unread' | 'read' | 'dismissed';
createdAt: string;
}
interface BroadcastConfig {
baseURL: string;
productId: string;
getAuthToken: () => Promise<string>;
}
```
## 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

View File

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

View File

@ -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<string, string>;
}
export type DeepLinkHandler = (route: DeepLinkRoute) => void;
/**
* Deep Link Router class
*/
export class DeepLinkRouter {
private handlers = new Map<string, DeepLinkHandler>();
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<string, string> = {};
// 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<string, string> = {};
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<string, string>,
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();

View File

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

View File

@ -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<string>);
/** 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<void>;
/** Mark message as dismissed */
markDismissed(messageId: string): Promise<void>;
/** 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 <T>(path: string, options?: RequestInit): Promise<T> => {
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<T>;
};
let pollInterval: ReturnType<typeof setInterval> | null = null;
return {
async listMessages() {
return request<{ messages: InAppMessage[] }>('/broadcasts');
},
async markRead(messageId: string) {
await request<void>(`/broadcasts/${messageId}/read`, { method: 'POST' });
},
async markDismissed(messageId: string) {
await request<void>(`/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';

View File

@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

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

View File

@ -1,25 +0,0 @@
export interface Celebration {
emoji: string;
title: string;
}
const DEFAULT_CELEBRATION: Celebration = {
emoji: '👏',
title: 'Great Job!',
};
const BY_TYPE: Record<string, Celebration> = {
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;
},
};
}

View File

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

View File

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

View File

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

View File

@ -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<EncryptedField> {
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<string> {
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<CryptoKey> {
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<CryptoKey> {
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<string> {
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<CryptoKey> {
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']
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>).customField).toBe('hello');
});
});
describe('PlatformSchema', () => {
it('accepts valid platforms', () => {
for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) {
expect(PlatformSchema.parse(p)).toBe(p);
}
});
it('rejects invalid platform', () => {
expect(() => PlatformSchema.parse('windows')).toThrow();
});
});
describe('ThemeSchema', () => {
it('validates hex colors', () => {
const result = ThemeSchema.parse(DEFAULT_THEME);
expect(result.primary).toBe('#5AE68C');
});
it('rejects non-hex colors', () => {
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/);
});
it('rejects 3-digit hex', () => {
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow();
});
});
describe('ContainerDefSchema', () => {
it('parses valid container', () => {
const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' });
expect(result.name).toBe('users');
});
it('accepts optional ttlSeconds and uniqueKeys', () => {
const result = ContainerDefSchema.parse({
name: 'sessions',
partitionKey: '/userId',
ttlSeconds: 86400,
uniqueKeys: ['/email'],
});
expect(result.ttlSeconds).toBe(86400);
expect(result.uniqueKeys).toEqual(['/email']);
});
it('rejects empty name', () => {
expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow();
});
it('rejects negative ttlSeconds', () => {
expect(() =>
ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 })
).toThrow();
});
});
describe('FeatureFlagSchema', () => {
it('accepts boolean default', () => {
const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false });
expect(result.defaultValue).toBe(false);
});
it('accepts string default', () => {
const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' });
expect(result.defaultValue).toBe('free');
});
it('accepts number default', () => {
const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 });
expect(result.defaultValue).toBe(100);
});
it('rejects empty key', () => {
expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow();
});
});
describe('BundleIdSchema', () => {
it('accepts string bundle ID', () => {
expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app');
});
it('accepts per-platform bundle IDs', () => {
const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' });
expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' });
});
it('rejects empty string', () => {
expect(() => BundleIdSchema.parse('')).toThrow();
});
});
describe('resolveTheme', () => {
it('returns defaults when no theme specified', () => {
const manifest = ProductManifestSchema.parse(MINIMAL);
const theme = resolveTheme(manifest);
expect(theme).toEqual(DEFAULT_THEME);
});
it('merges partial theme with defaults', () => {
const manifest = ProductManifestSchema.parse({
...MINIMAL,
theme: { primary: '#FF0000' },
});
const theme = resolveTheme(manifest);
expect(theme.primary).toBe('#FF0000');
expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved
});
});
describe('validateProductManifest', () => {
it('validates a valid object', () => {
const result = validateProductManifest(MINIMAL);
expect(result.productId).toBe('testprod');
});
it('throws on invalid object', () => {
expect(() => validateProductManifest({ bad: true })).toThrow();
});
});
describe('safeValidateProductManifest', () => {
it('returns manifest on valid input', () => {
const result = safeValidateProductManifest(MINIMAL);
expect(result).not.toBeNull();
expect(result!.productId).toBe('testprod');
});
it('returns null on invalid input', () => {
const result = safeValidateProductManifest({ bad: true });
expect(result).toBeNull();
});
});
describe('loadProductManifest / loadProductManifestSync', () => {
const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`);
const validPath = join(tmpDir, 'valid.json');
const invalidPath = join(tmpDir, 'invalid.json');
beforeEach(() => {
mkdirSync(tmpDir, { recursive: true });
writeFileSync(validPath, JSON.stringify(FULL));
writeFileSync(invalidPath, JSON.stringify({ bad: true }));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('loads and validates from file (async)', async () => {
const result = await loadProductManifest(validPath);
expect(result.productId).toBe('flowmonk');
expect(result.cosmos?.containers).toHaveLength(3);
});
it('loads and validates from file (sync)', () => {
const result = loadProductManifestSync(validPath);
expect(result.productId).toBe('flowmonk');
});
it('throws on invalid file (async)', async () => {
await expect(loadProductManifest(invalidPath)).rejects.toThrow();
});
it('throws on invalid file (sync)', () => {
expect(() => loadProductManifestSync(invalidPath)).toThrow();
});
it('throws on missing file (async)', async () => {
await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow();
});
it('throws on missing file (sync)', () => {
expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow();
});
});
describe('real-world product.json files parse cleanly', () => {
it('parses LysnrAI product.json', () => {
const json = {
displayName: 'LysnrAI',
productId: 'lysnrai',
licensePrefix: 'LYSNR',
configDirName: '.LysnrAI',
envVarPrefix: 'LYSNR',
bundleIdSuffix: 'LysnrAI',
packageName: 'lysnrai',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses NomGap product.json', () => {
const json = {
productId: 'nomgap',
displayName: 'NomGap',
bundleId: 'com.saravana.nomgap',
domain: 'nomgap.app',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses NoteLett product.json', () => {
const json = {
productId: 'notelett',
displayName: 'NoteLett',
licensePrefix: 'NOTELETT',
configDirName: '.NoteLett',
envVarPrefix: 'NOTELETT',
bundleIdSuffix: 'notelett',
packageName: 'notelett',
domain: 'notelett.app',
bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' },
appGroup: 'group.com.bytelyst.notelett',
backendPort: 4016,
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses FlowMonk product.json', () => {
expect(() => ProductManifestSchema.parse(FULL)).not.toThrow();
});
it('parses ActionTrail product.json', () => {
const json = {
productId: 'actiontrail',
displayName: 'ActionTrail',
tagline: 'AI Activity Oversight',
domain: 'actiontrail.dev',
backendPort: 4018,
primarySurface: 'web',
mobileCompanion: false,
bundleIds: { web: 'actiontrail.dev' },
appStore: {
category: 'Developer Tools',
subcategory: 'AI Monitoring',
privacyUrl: 'https://actiontrail.dev/privacy',
termsUrl: 'https://actiontrail.dev/terms',
supportUrl: 'https://actiontrail.dev/support',
},
version: '0.1.0',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
});

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More