fix(docker): add missing @bytelyst packages to vendor directory for Docker builds

This commit is contained in:
root 2026-05-09 21:49:45 +00:00
parent 6f9c97e80c
commit 69cd2b30f3
582 changed files with 63498 additions and 2 deletions

View File

@ -0,0 +1,20 @@
{
"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

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

@ -0,0 +1,18 @@
{
"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

@ -0,0 +1,24 @@
{
"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

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

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

@ -0,0 +1,527 @@
/**
* 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

@ -0,0 +1,16 @@
export { createAuthClient } from './client.js';
export type {
AuthClient,
AuthClientConfig,
AuthProvider,
AuthResult,
AuthUser,
Device,
LoginEventInfo,
MfaRequiredResult,
MfaStatus,
Passkey,
SecurityOverview,
TokenStorage,
TotpSetupResult,
} from './types.js';

190
vendor/bytelyst/auth-client/src/types.ts vendored Normal file
View File

@ -0,0 +1,190 @@
/**
* 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

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

36
vendor/bytelyst/auth-ui/package.json vendored Normal file
View File

@ -0,0 +1,36 @@
{
"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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,155 @@
// @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

@ -0,0 +1,402 @@
// @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');
});
});

24
vendor/bytelyst/auth-ui/src/index.ts vendored Normal file
View File

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

147
vendor/bytelyst/auth-ui/src/types.ts vendored Normal file
View File

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

11
vendor/bytelyst/auth-ui/tsconfig.json vendored Normal file
View File

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

View File

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

View File

@ -18,7 +18,7 @@
"test": "vitest run --pool forks"
},
"dependencies": {
"@bytelyst/errors": "file:../errors"
"@bytelyst/errors": "workspace:*"
},
"peerDependencies": {
"jose": ">=5.0.0",

View File

@ -0,0 +1,33 @@
{
"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

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

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

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

View File

@ -0,0 +1,30 @@
{
"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

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

@ -0,0 +1,38 @@
/**
* 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

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

View File

@ -0,0 +1,30 @@
{
"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

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

@ -0,0 +1,50 @@
/**
* 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

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

View File

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

View File

@ -0,0 +1,28 @@
{
"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

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

@ -0,0 +1,214 @@
/**
* @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

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

View File

@ -0,0 +1,24 @@
{
"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

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

289
vendor/bytelyst/blob-client/src/index.ts vendored Normal file
View File

@ -0,0 +1,289 @@
/**
* 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

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

27
vendor/bytelyst/blob/package.json vendored Normal file
View File

@ -0,0 +1,27 @@
{
"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

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

94
vendor/bytelyst/blob/src/blob.ts vendored Normal file
View File

@ -0,0 +1,94 @@
/**
* 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();
}

1
vendor/bytelyst/blob/src/index.ts vendored Normal file
View File

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

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

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

View File

@ -0,0 +1,227 @@
# @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

@ -0,0 +1,24 @@
{
"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

@ -0,0 +1,165 @@
/**
* 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

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

@ -0,0 +1,185 @@
/**
* 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

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

View File

@ -0,0 +1,19 @@
{
"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

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

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

View File

@ -0,0 +1,26 @@
{
"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

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

@ -0,0 +1,215 @@
/**
* @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

@ -0,0 +1,22 @@
/**
* @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

@ -0,0 +1,28 @@
/**
* @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

@ -0,0 +1,36 @@
/**
* @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

@ -0,0 +1,33 @@
/**
* @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

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

26
vendor/bytelyst/create-app/package.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@bytelyst/create-app",
"version": "0.1.3",
"description": "CLI tools for scaffolding ByteLyst product repos and code",
"type": "module",
"bin": {
"create-app": "./dist/scaffolder.js",
"gen-api-route": "./dist/generators/api-routes.js",
"gen-agents-md": "./dist/generators/agents-md.js"
},
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"create-app": "tsx src/scaffolder.ts",
"gen:api-route": "tsx src/generators/api-routes.ts",
"gen:agents-md": "tsx src/generators/agents-md.ts"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"publishConfig": {
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
}
}

View File

@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { generateFiles, type ProductManifest } from '../scaffolder.js';
function makeManifest(overrides: Partial<ProductManifest> = {}): ProductManifest {
return {
productId: 'testapp',
displayName: 'TestApp',
tagline: 'A test application',
domain: 'testapp.dev',
backendPort: 4050,
primarySurface: 'web',
platforms: ['web'],
features: ['auth', 'telemetry'],
...overrides,
};
}
describe('generateFiles', () => {
it('generates root files', () => {
const files = generateFiles(makeManifest());
const paths = files.map(f => f.path);
expect(paths).toContain('shared/product.json');
expect(paths).toContain('.gitignore');
expect(paths).toContain('.env.example');
expect(paths).toContain('README.md');
});
it('always generates backend files', () => {
const files = generateFiles(makeManifest());
const paths = files.map(f => f.path);
expect(paths).toContain('backend/package.json');
expect(paths).toContain('backend/src/server.ts');
expect(paths).toContain('backend/src/lib/config.ts');
expect(paths).toContain('backend/src/lib/auth.ts');
expect(paths).toContain('backend/src/lib/datastore.ts');
});
it('generates web files when platform includes web', () => {
const files = generateFiles(makeManifest({ platforms: ['web'] }));
const paths = files.map(f => f.path);
expect(paths).toContain('web/package.json');
expect(paths).toContain('web/next.config.ts');
expect(paths).toContain('web/src/app/layout.tsx');
expect(paths).toContain('web/src/app/page.tsx');
expect(paths).toContain('web/src/lib/product-config.ts');
});
it('does not generate web files when platform excludes web', () => {
const files = generateFiles(makeManifest({ platforms: ['mobile'] }));
const paths = files.map(f => f.path);
expect(paths).not.toContain('web/package.json');
expect(paths).not.toContain('web/src/app/page.tsx');
});
it('generates mobile files when platform includes mobile', () => {
const files = generateFiles(makeManifest({ platforms: ['mobile'] }));
const paths = files.map(f => f.path);
expect(paths).toContain('mobile/package.json');
expect(paths).toContain('mobile/app.json');
expect(paths).toContain('mobile/src/app/index.tsx');
});
it('does not generate mobile files when platform excludes mobile', () => {
const files = generateFiles(makeManifest({ platforms: ['web'] }));
const paths = files.map(f => f.path);
expect(paths).not.toContain('mobile/package.json');
});
it('replaces product ID in generated content', () => {
const files = generateFiles(makeManifest({ productId: 'myproduct' }));
const productJson = files.find(f => f.path === 'shared/product.json')!;
expect(productJson.content).toContain('"productId": "myproduct"');
});
it('replaces display name in generated content', () => {
const files = generateFiles(makeManifest({ displayName: 'AwesomeApp' }));
const readme = files.find(f => f.path === 'README.md')!;
expect(readme.content).toContain('# AwesomeApp');
});
it('replaces backend port in config', () => {
const files = generateFiles(makeManifest({ backendPort: 4099 }));
const config = files.find(f => f.path === 'backend/src/lib/config.ts')!;
expect(config.content).toContain('4099');
});
it('includes ios bundle ID when ios platform selected', () => {
const files = generateFiles(makeManifest({ platforms: ['web', 'ios'], productId: 'myapp' }));
const productJson = files.find(f => f.path === 'shared/product.json')!;
expect(productJson.content).toContain('com.bytelyst.myapp');
});
it('includes android bundle ID when android platform selected', () => {
const files = generateFiles(
makeManifest({ platforms: ['web', 'android'], productId: 'myapp' })
);
const productJson = files.find(f => f.path === 'shared/product.json')!;
expect(productJson.content).toContain('com.myapp.app');
});
it('includes backend env vars in .env.example', () => {
const files = generateFiles(makeManifest({ backendPort: 4050 }));
const env = files.find(f => f.path === '.env.example')!;
expect(env.content).toContain('PORT=4050');
expect(env.content).toContain('JWT_SECRET');
});
it('generates correct web product-config', () => {
const files = generateFiles(makeManifest({ productId: 'testprod', backendPort: 4077 }));
const webConfig = files.find(f => f.path === 'web/src/lib/product-config.ts')!;
expect(webConfig.content).toContain("productId: 'testprod'");
expect(webConfig.content).toContain('4077');
});
it('generates all platforms when all selected', () => {
const files = generateFiles(makeManifest({ platforms: ['web', 'mobile', 'ios', 'android'] }));
const paths = files.map(f => f.path);
expect(paths).toContain('web/package.json');
expect(paths).toContain('mobile/package.json');
// Backend is always included
expect(paths).toContain('backend/package.json');
});
it('server.ts includes display name in log message', () => {
const files = generateFiles(makeManifest({ displayName: 'CoolProduct' }));
const server = files.find(f => f.path === 'backend/src/server.ts')!;
expect(server.content).toContain('CoolProduct backend listening');
});
});

View File

@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { renderTemplate } from '../lib/template-engine.js';
describe('renderTemplate', () => {
it('replaces simple variables', () => {
const result = renderTemplate('Hello {{NAME}}!', { NAME: 'World' });
expect(result).toBe('Hello World!');
});
it('replaces multiple variables', () => {
const result = renderTemplate('{{A}} and {{B}}', { A: 'foo', B: 'bar' });
expect(result).toBe('foo and bar');
});
it('replaces numeric variables', () => {
const result = renderTemplate('Port: {{PORT}}', { PORT: 4017 });
expect(result).toBe('Port: 4017');
});
it('leaves unknown variables intact', () => {
const result = renderTemplate('{{KNOWN}} {{UNKNOWN}}', { KNOWN: 'yes' });
expect(result).toBe('yes {{UNKNOWN}}');
});
it('includes IF block when truthy', () => {
const result = renderTemplate('{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}', { HAS_WEB: true });
expect(result).toBe('web here');
});
it('excludes IF block when falsy', () => {
const result = renderTemplate('before{{#IF HAS_WEB}}web here{{/IF HAS_WEB}}after', {
HAS_WEB: false,
});
expect(result).toBe('beforeafter');
});
it('includes UNLESS block when falsy', () => {
const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', {
HAS_WEB: false,
});
expect(result).toBe('no web');
});
it('excludes UNLESS block when truthy', () => {
const result = renderTemplate('{{#UNLESS HAS_WEB}}no web{{/UNLESS HAS_WEB}}', {
HAS_WEB: true,
});
expect(result).toBe('');
});
it('handles nested variables inside IF blocks', () => {
const result = renderTemplate('{{#IF HAS_WEB}}Port: {{PORT}}{{/IF HAS_WEB}}', {
HAS_WEB: true,
PORT: 3000,
});
expect(result).toBe('Port: 3000');
});
it('handles multiline IF blocks', () => {
const tmpl = `start
{{#IF HAS_BACKEND}}backend line 1
backend line 2
{{/IF HAS_BACKEND}}end`;
const result = renderTemplate(tmpl, { HAS_BACKEND: true });
expect(result).toContain('backend line 1');
expect(result).toContain('backend line 2');
expect(result).toContain('start');
expect(result).toContain('end');
});
it('handles multiple IF blocks', () => {
const tmpl = '{{#IF A}}aaa{{/IF A}}|{{#IF B}}bbb{{/IF B}}';
expect(renderTemplate(tmpl, { A: true, B: false })).toBe('aaa|');
expect(renderTemplate(tmpl, { A: false, B: true })).toBe('|bbb');
expect(renderTemplate(tmpl, { A: true, B: true })).toBe('aaa|bbb');
});
});

View File

@ -0,0 +1,605 @@
#!/usr/bin/env node
/**
* AGENTS.md Auto-Generator
*
* Generates AGENTS.md from product.json + repo directory scan.
* Also creates/updates symlinks: CLAUDE.md, .cursorrules, .windsurfrules
*
* Usage:
* npx tsx agents-md.ts --repo /path/to/product-repo
* npx tsx agents-md.ts --repo /path/to/product-repo --dry-run
* npx tsx agents-md.ts --repo /path/to/product-repo --update # preserves <!-- CUSTOM --> sections
*
* @module @bytelyst/create-app/generators/agents-md
*/
/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
// ── CLI ──────────────────────────────────────────────────────────────────────
interface Options {
repo: string;
dryRun: boolean;
update: boolean;
}
function parseArgs(): Options {
const args = process.argv.slice(2);
const options: Options = { repo: '.', dryRun: false, update: false };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--repo' || arg === '-r') options.repo = args[++i];
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
else if (arg === '--update' || arg === '-u') options.update = true;
else if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
}
}
return options;
}
function showHelp(): void {
console.log(`
AGENTS.md Auto-Generator
Generates AGENTS.md from product.json + repo scan, plus symlinks for
CLAUDE.md, .cursorrules, and .windsurfrules.
Usage:
npx tsx agents-md.ts --repo <path> [--dry-run] [--update]
Options:
--repo, -r Path to product repo root (default: ".")
--dry-run, -d Preview without writing files
--update, -u Preserve <!-- CUSTOM --> sections in existing AGENTS.md
--help, -h Show this help
Custom Sections:
Wrap any hand-written content in <!-- CUSTOM:key --> / <!-- /CUSTOM:key -->
markers. The --update flag preserves these sections when regenerating.
`);
}
// ── Product.json Loader ──────────────────────────────────────────────────────
interface ProductManifest {
productId: string;
displayName: string;
tagline?: string;
domain?: string;
backendPort?: number;
primarySurface?: string;
mobileCompanion?: boolean;
bundleIds?: Record<string, string>;
bundleId?: string;
version?: string;
description?: string;
licensePrefix?: string;
configDirName?: string;
envVarPrefix?: string;
}
async function loadProductJson(repoPath: string): Promise<ProductManifest> {
const candidates = [
path.join(repoPath, 'shared', 'product.json'),
path.join(repoPath, 'product.json'),
];
for (const p of candidates) {
try {
const raw = await fs.readFile(p, 'utf-8');
return JSON.parse(raw);
} catch {
// try next
}
}
throw new Error('product.json not found in shared/ or repo root');
}
// ── Repo Scanner ─────────────────────────────────────────────────────────────
interface RepoInfo {
repoName: string;
hasFastifyBackend: boolean;
hasNextWeb: boolean;
hasExpoMobile: boolean;
hasSwiftIos: boolean;
hasKotlinAndroid: boolean;
hasKmpShared: boolean;
backendModules: string[];
backendTestCount: number;
webTestCount: number;
mobileTestCount: number;
backendLibFiles: string[];
webLibFiles: string[];
cosmosContainers: string[];
techStack: { layer: string; tech: string }[];
buildCommands: string[];
}
async function dirExists(p: string): Promise<boolean> {
try {
const st = await fs.stat(p);
return st.isDirectory();
} catch {
return false;
}
}
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
function countTestsInFiles(dir: string): number {
try {
const output = execSync(
`grep -r "\\b\\(it\\|test\\)(" "${dir}" --include="*.test.ts" --include="*.test.tsx" --include="*.spec.ts" 2>/dev/null | wc -l`,
{ encoding: 'utf-8', timeout: 5000 }
);
return parseInt(output.trim()) || 0;
} catch {
return 0;
}
}
async function listDirs(dir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries.filter(e => e.isDirectory()).map(e => e.name);
} catch {
return [];
}
}
async function listFiles(dir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries.filter(e => e.isFile()).map(e => e.name);
} catch {
return [];
}
}
async function scanRepo(repoPath: string, manifest: ProductManifest): Promise<RepoInfo> {
const repoName = path.basename(repoPath);
// Detect surfaces
const hasFastifyBackend = await dirExists(path.join(repoPath, 'backend', 'src'));
const hasNextWeb =
(await fileExists(path.join(repoPath, 'web', 'next.config.ts'))) ||
(await fileExists(path.join(repoPath, 'web', 'next.config.js'))) ||
(await fileExists(path.join(repoPath, 'mindlyst-native', 'web', 'next.config.ts')));
const hasExpoMobile =
(await fileExists(path.join(repoPath, 'mobile', 'app.json'))) ||
(await fileExists(path.join(repoPath, 'app.json')));
const hasSwiftIos = await dirExists(path.join(repoPath, 'ios'));
const hasKotlinAndroid = await dirExists(path.join(repoPath, 'android'));
const hasKmpShared = await dirExists(path.join(repoPath, 'shared', 'src'));
// Backend modules
const modulesDir = path.join(repoPath, 'backend', 'src', 'modules');
const backendModules = await listDirs(modulesDir);
// Backend lib files
const libDir = path.join(repoPath, 'backend', 'src', 'lib');
const backendLibFiles = (await listFiles(libDir)).filter(
f => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
// Web lib files
let webLibDir = path.join(repoPath, 'web', 'src', 'lib');
if (!(await dirExists(webLibDir))) {
webLibDir = path.join(repoPath, 'web', 'src', 'components', 'lib');
}
const webLibFiles = (await listFiles(webLibDir)).filter(
f => f.endsWith('.ts') && !f.endsWith('.test.ts')
);
// Test counts
const backendTestCount = hasFastifyBackend
? countTestsInFiles(path.join(repoPath, 'backend', 'src'))
: 0;
let webTestDir = path.join(repoPath, 'web', 'src');
if (!(await dirExists(webTestDir))) {
webTestDir = path.join(repoPath, 'mindlyst-native', 'web', 'src');
}
const webTestCount = hasNextWeb ? countTestsInFiles(webTestDir) : 0;
const mobileTestDir = hasExpoMobile
? (await dirExists(path.join(repoPath, 'mobile')))
? path.join(repoPath, 'mobile')
: repoPath
: '';
const mobileTestCount = mobileTestDir ? countTestsInFiles(mobileTestDir) : 0;
// Cosmos containers — scan backend types files
const cosmosContainers: string[] = [];
for (const mod of backendModules) {
const typesFile = path.join(modulesDir, mod, 'types.ts');
if (await fileExists(typesFile)) {
cosmosContainers.push(mod.replace(/-/g, '_'));
}
}
// Tech stack
const techStack: { layer: string; tech: string }[] = [];
if (hasFastifyBackend)
techStack.push({
layer: 'Backend',
tech: `Fastify 5, TypeScript ESM, Zod, jose (JWT), @bytelyst/datastore`,
});
if (hasNextWeb)
techStack.push({ layer: 'Web', tech: 'Next.js 16 (App Router), React 19, TypeScript' });
if (hasExpoMobile)
techStack.push({ layer: 'Mobile', tech: 'React Native (Expo), TypeScript, expo-router' });
if (hasSwiftIos) techStack.push({ layer: 'iOS', tech: 'SwiftUI (iOS 17+)' });
if (hasKotlinAndroid)
techStack.push({ layer: 'Android', tech: 'Jetpack Compose, Material 3, Kotlin' });
if (hasKmpShared) techStack.push({ layer: 'Shared', tech: 'Kotlin Multiplatform (KMP)' });
techStack.push({
layer: 'Platform',
tech: 'platform-service (port 4003) for auth, flags, telemetry, billing',
});
techStack.push({
layer: 'Database',
tech: `Azure Cosmos DB via @bytelyst/datastore — productId: "${manifest.productId}"`,
});
// Build commands
const buildCommands: string[] = [];
if (hasFastifyBackend) {
const port = manifest.backendPort ?? 4000;
buildCommands.push(`cd backend && npm run dev # Dev server (port ${port})`);
buildCommands.push(`cd backend && npm run typecheck # tsc --noEmit`);
buildCommands.push(`cd backend && npm test # Vitest tests`);
}
if (hasNextWeb) {
buildCommands.push(`cd web && npm run dev # Dev server`);
buildCommands.push(`cd web && npm run typecheck # tsc --noEmit`);
buildCommands.push(`cd web && npm run build # Production build`);
}
if (hasExpoMobile) {
buildCommands.push(`cd mobile && npm start # Expo dev server`);
buildCommands.push(`cd mobile && npm run typecheck # tsc --noEmit`);
}
return {
repoName,
hasFastifyBackend,
hasNextWeb,
hasExpoMobile,
hasSwiftIos,
hasKotlinAndroid,
hasKmpShared,
backendModules,
backendTestCount,
webTestCount,
mobileTestCount,
backendLibFiles,
webLibFiles,
cosmosContainers,
techStack,
buildCommands,
};
}
// ── Markdown Generator ───────────────────────────────────────────────────────
function generateAgentsMd(manifest: ProductManifest, info: RepoInfo): string {
const { productId, displayName, domain, tagline } = manifest;
const repoDesc = tagline ?? `${displayName} product`;
const lines: string[] = [];
// ── Header
lines.push(`# AGENTS.md — AI Coding Agent Instructions`);
lines.push('');
lines.push(
`> **For:** Claude Code, OpenAI Codex, Cursor, GitHub Copilot, Windsurf Cascade, and any AI coding agent.`
);
lines.push(`> **Repo:** \`${info.repoName}\`${repoDesc}.`);
lines.push('');
lines.push('---');
lines.push('');
// ── 1. Project Identity
lines.push('## 1. Project Identity');
lines.push('');
lines.push('| Key | Value |');
lines.push('|-----|-------|');
lines.push(`| **Product** | ${displayName} |`);
lines.push(`| **Product ID** | \`${productId}\` |`);
if (domain) lines.push(`| **Domain** | ${domain} |`);
lines.push(`| **Repo** | \`${info.repoName}\` |`);
lines.push(`| **Ecosystem** | ByteLyst (shares platform-service with other ByteLyst products) |`);
lines.push('');
// ── 2. Repo Layout (simplified tree)
lines.push('## 2. Repo Layout');
lines.push('');
lines.push('```');
lines.push(`${info.repoName}/`);
if (info.hasFastifyBackend) {
const port = manifest.backendPort ?? 4000;
lines.push(
`├── backend/ # Fastify 5 + TypeScript ESM backend (port ${port})`
);
lines.push(`│ ├── src/`);
if (info.backendLibFiles.length > 0) {
lines.push(`│ │ ├── lib/ # Shared backend wiring`);
for (const f of info.backendLibFiles.slice(0, 8)) {
lines.push(`│ │ │ ├── ${f}`);
}
if (info.backendLibFiles.length > 8) {
lines.push(`│ │ │ └── ... (${info.backendLibFiles.length - 8} more)`);
}
}
if (info.backendModules.length > 0) {
lines.push(`│ │ ├── modules/`);
for (const m of info.backendModules) {
lines.push(`│ │ │ ├── ${m}/`);
}
}
lines.push(`│ │ └── server.ts`);
lines.push(`│ ├── package.json`);
lines.push(`│ └── tsconfig.json`);
lines.push('│');
}
if (info.hasNextWeb) {
lines.push(`├── web/ # Next.js 16 + React 19 (App Router)`);
lines.push(`│ ├── src/`);
lines.push(`│ │ ├── app/ # App Router pages`);
if (info.webLibFiles.length > 0) {
lines.push(`│ │ └── lib/ # Pure TS clients + config`);
}
lines.push(`│ ├── package.json`);
lines.push(`│ └── tsconfig.json`);
lines.push('│');
}
if (info.hasExpoMobile) {
lines.push(`├── mobile/ # React Native + Expo`);
lines.push('│');
}
if (info.hasSwiftIos) {
lines.push(`├── ios/ # SwiftUI native app`);
lines.push('│');
}
if (info.hasKotlinAndroid) {
lines.push(`├── android/ # Jetpack Compose`);
lines.push('│');
}
lines.push(`├── shared/`);
lines.push(`│ └── product.json # Canonical product identity`);
lines.push(`├── AGENTS.md # This file`);
lines.push(`└── README.md`);
lines.push('```');
lines.push('');
// ── 3. Tech Stack
lines.push('## 3. Tech Stack');
lines.push('');
lines.push('| Layer | Technology |');
lines.push('|-------|-----------|');
for (const { layer, tech } of info.techStack) {
lines.push(`| **${layer}** | ${tech} |`);
}
// Test counts
const totalTests = info.backendTestCount + info.webTestCount + info.mobileTestCount;
if (totalTests > 0) {
const parts: string[] = [];
if (info.backendTestCount > 0) parts.push(`${info.backendTestCount} backend`);
if (info.webTestCount > 0) parts.push(`${info.webTestCount} web`);
if (info.mobileTestCount > 0) parts.push(`${info.mobileTestCount} mobile`);
lines.push(`| **Tests** | Vitest — ~${totalTests} tests (${parts.join(' + ')}) |`);
}
lines.push('');
// ── 4. Coding Conventions
lines.push('## 4. Coding Conventions');
lines.push('');
lines.push('### MUST follow');
lines.push('');
lines.push(`- Every Cosmos document MUST include a \`productId: "${productId}"\` field`);
if (info.hasFastifyBackend) {
lines.push('- Backend modules follow `types.ts` → `repository.ts` → `routes.ts` pattern');
lines.push(
'- All repositories use `@bytelyst/datastore` getCollection() — never direct Cosmos SDK calls'
);
}
if (info.hasNextWeb) {
lines.push('- Web engine logic in `web/src/lib/` — pure TS, no React imports');
lines.push('- Web components in `web/src/components/` — React UI only');
}
if (info.hasExpoMobile) {
lines.push('- Mobile engine logic in `mobile/src/lib/` — pure TS, no React Native imports');
}
lines.push(
'- Commit messages: `type(scope): description` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`'
);
lines.push('');
lines.push('### MUST NOT do');
lines.push('');
lines.push(
'- Never use `console.log` in production code — use `req.log` or `app.log` in Fastify'
);
lines.push('- Never use `any` type — use Zod inference or explicit types');
lines.push('- Never hardcode colors — use theme tokens');
lines.push('- Never hardcode API URLs — use env vars or config');
lines.push(`- Never hardcode product ID — use \`productConfig.productId\``);
lines.push('- Never modify tests to make them pass — fix the actual code');
lines.push('- Never delete existing comments or documentation unless explicitly asked');
lines.push('- Never add emojis to code unless explicitly asked');
lines.push('');
// ── 5. Build & Test Commands
if (info.buildCommands.length > 0) {
lines.push('## 5. Build & Test Commands');
lines.push('');
lines.push('```bash');
for (const cmd of info.buildCommands) {
lines.push(cmd);
}
lines.push('```');
lines.push('');
}
// ── 6. Backend API Modules (if applicable)
if (info.hasFastifyBackend && info.backendModules.length > 0) {
lines.push('## 6. Backend Modules');
lines.push('');
lines.push('| Module | Container | Description |');
lines.push('|--------|-----------|-------------|');
for (const mod of info.backendModules) {
const container = mod.replace(/-/g, '_');
lines.push(`| \`${mod}\` | \`${container}\` | ${mod.replace(/-/g, ' ')} |`);
}
lines.push('');
}
// ── Custom section placeholder
lines.push('<!-- CUSTOM:extra -->');
lines.push('<!-- /CUSTOM:extra -->');
lines.push('');
return lines.join('\n');
}
// ── Custom Section Preservation ──────────────────────────────────────────────
function extractCustomSections(content: string): Map<string, string> {
const sections = new Map<string, string>();
const regex = /<!-- CUSTOM:(\w+) -->\n([\s\S]*?)<!-- \/CUSTOM:\1 -->/g;
let match;
while ((match = regex.exec(content)) !== null) {
sections.set(match[1], match[2]);
}
return sections;
}
function mergeCustomSections(newContent: string, existing: Map<string, string>): string {
let result = newContent;
for (const [key, value] of existing) {
const placeholder = `<!-- CUSTOM:${key} -->\n<!-- /CUSTOM:${key} -->`;
const replacement = `<!-- CUSTOM:${key} -->\n${value}<!-- /CUSTOM:${key} -->`;
result = result.replace(placeholder, replacement);
}
return result;
}
// ── Symlink Manager ──────────────────────────────────────────────────────────
async function ensureSymlinks(repoPath: string, dryRun: boolean): Promise<void> {
const targets = ['CLAUDE.md', '.cursorrules', '.windsurfrules'];
for (const target of targets) {
const linkPath = path.join(repoPath, target);
const exists = await fileExists(linkPath);
if (exists) {
try {
const stat = await fs.lstat(linkPath);
if (stat.isSymbolicLink()) {
const linkTarget = await fs.readlink(linkPath);
if (linkTarget === 'AGENTS.md') {
continue; // already correct
}
}
} catch {
// not a symlink
}
}
if (dryRun) {
console.log(` 📝 Would create symlink: ${target} → AGENTS.md`);
} else {
try {
if (exists) await fs.unlink(linkPath);
await fs.symlink('AGENTS.md', linkPath);
console.log(`${target} → AGENTS.md`);
} catch (err) {
console.log(
` ⚠️ Could not create symlink ${target}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const { repo, dryRun, update } = parseArgs();
const repoPath = path.resolve(repo);
console.log(`\n📄 AGENTS.md Generator`);
console.log(` Repo: ${repoPath}`);
if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n');
if (update) console.log(' 🔄 UPDATE mode — preserving custom sections\n');
// Load product.json
const manifest = await loadProductJson(repoPath);
console.log(` Product: ${manifest.displayName} (${manifest.productId})`);
// Scan repo
const info = await scanRepo(repoPath, manifest);
console.log(` Backend modules: ${info.backendModules.length}`);
console.log(` Tests: ~${info.backendTestCount + info.webTestCount + info.mobileTestCount}`);
console.log('');
// Generate content
let content = generateAgentsMd(manifest, info);
// Merge custom sections if updating
if (update) {
const agentsPath = path.join(repoPath, 'AGENTS.md');
try {
const existing = await fs.readFile(agentsPath, 'utf-8');
const customSections = extractCustomSections(existing);
if (customSections.size > 0) {
console.log(` 🔄 Preserving ${customSections.size} custom section(s)`);
content = mergeCustomSections(content, customSections);
}
} catch {
console.log(' No existing AGENTS.md to preserve custom sections from');
}
}
if (dryRun) {
console.log('── AGENTS.md ──────────────────────────────────────');
console.log(content);
console.log('\n── Symlinks ──────────────────────────────────────');
await ensureSymlinks(repoPath, true);
console.log('\n✨ Dry run complete.');
return;
}
// Write AGENTS.md
const agentsPath = path.join(repoPath, 'AGENTS.md');
await fs.writeFile(agentsPath, content, 'utf-8');
console.log(` ✅ AGENTS.md written`);
// Ensure symlinks
await ensureSymlinks(repoPath, false);
console.log(`\n✨ AGENTS.md generated for ${manifest.displayName}.`);
}
main().catch(err => {
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});

View File

@ -0,0 +1,770 @@
#!/usr/bin/env node
/**
* API Route Generator Next.js App Router
*
* Generates two files:
* src/app/api/<name>/route.ts GET (list) + POST (create)
* src/app/api/<name>/[id]/route.ts GET (detail) + PATCH (update) + DELETE
*
* Follows the pattern used across ByteLyst product dashboards:
* - Named exports (export const GET, POST, PATCH, DELETE)
* - withErrorHandler HOF wrapper
* - Auth via getCurrentUser / getAccessToken
* - Zod validation on POST/PATCH bodies
* - NextRequest + NextResponse
*
* Usage:
* npx tsx src/generators/api-routes.ts --name tasks --fields "title:string,status:enum(pending,active,done),priority:number?" --target ../some-web/src
* npx tsx src/generators/api-routes.ts --name tasks --fields "title:string" --mode proxy --target ../some-web/src
*
* Modes:
* --mode direct (default) Direct Cosmos DB access via repository functions
* --mode proxy Proxy to product backend via fetch (for thin web clients)
*
* @module @bytelyst/create-app/generators/api-routes
*/
/* eslint-disable no-console -- This generator is a CLI; console output is its user interface. */
import { promises as fs } from 'node:fs';
import path from 'node:path';
// ── CLI ──────────────────────────────────────────────────────────────────────
interface Options {
name: string;
fields: string;
target: string;
mode: 'direct' | 'proxy';
methods: string[];
withHandler: boolean;
dryRun: boolean;
}
function parseArgs(): Options {
const args = process.argv.slice(2);
const options: Options = {
name: '',
fields: '',
target: './src',
mode: 'direct',
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
withHandler: true,
dryRun: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--name' || arg === '-n') options.name = args[++i];
else if (arg === '--fields' || arg === '-f') options.fields = args[++i];
else if (arg === '--target' || arg === '-t') options.target = args[++i];
else if (arg === '--mode' || arg === '-m') options.mode = args[++i] as 'direct' | 'proxy';
else if (arg === '--methods')
options.methods = args[++i].split(',').map(m => m.trim().toUpperCase());
else if (arg === '--no-handler') options.withHandler = false;
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
else if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
}
}
if (!options.name) {
console.error('Error: --name is required');
showHelp();
process.exit(1);
}
if (!/^[a-z][a-z0-9-]*$/.test(options.name)) {
console.error('Error: --name must be lowercase alphanumeric with optional hyphens');
process.exit(1);
}
if (!['direct', 'proxy'].includes(options.mode)) {
console.error('Error: --mode must be "direct" or "proxy"');
process.exit(1);
}
return options;
}
function showHelp(): void {
console.log(`
API Route Generator Next.js App Router
Generates CRUD API route files for a Next.js App Router project.
Usage:
npx tsx api-routes.ts --name <name> [--fields "<field:type,...>"] [options]
Options:
--name, -n Entity name (e.g., "tasks", "sessions") [required]
--fields, -f Comma-separated field definitions [optional for proxy mode]
--target, -t Target src/ directory (default: "./src")
--mode, -m "direct" (Cosmos DB) or "proxy" (backend fetch) [default: direct]
--methods HTTP methods to generate (default: GET,POST,PATCH,DELETE)
--no-handler Skip withErrorHandler wrapper
--dry-run, -d Preview without writing files
--help, -h Show this help
Field Types (same as gen-module):
string z.string()
number z.number()
boolean z.boolean()
date z.string().datetime()
enum(a,b,c) z.enum(["a","b","c"])
Append ? for optional fields
Modes:
direct Generates route files that call repository functions (direct Cosmos DB).
Also generates a lib/repositories/<name>.ts and lib/schemas/<name>.ts.
proxy Generates route files that proxy to a product backend via fetch.
Requires lib/api-helpers.ts to exist (createApiClient pattern).
Examples:
# Direct mode (full CRUD with Cosmos)
npx tsx api-routes.ts --name tasks \\
--fields "title:string,status:enum(pending,active,done),priority:number?" \\
--target ./src
# Proxy mode (thin web client forwarding to backend)
npx tsx api-routes.ts --name tasks --mode proxy --target ./src
# Preview only
npx tsx api-routes.ts --name tasks --fields "title:string" --dry-run
`);
}
// ── Field Parser ─────────────────────────────────────────────────────────────
interface ParsedField {
name: string;
type: string;
optional: boolean;
zodType: string;
tsType: string;
enumValues: string[] | null;
}
function splitFields(str: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = '';
for (const ch of str) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) {
parts.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
function parseFields(fieldsStr: string): ParsedField[] {
if (!fieldsStr) return [];
const fields: ParsedField[] = [];
const fieldDefs = splitFields(fieldsStr);
for (const raw of fieldDefs) {
const def = raw.trim();
if (!def) continue;
const optional = def.endsWith('?');
const cleaned = optional ? def.slice(0, -1) : def;
const colonIdx = cleaned.indexOf(':');
if (colonIdx === -1) throw new Error(`Invalid field (missing type): ${def}`);
const name = cleaned.slice(0, colonIdx).trim();
const type = cleaned.slice(colonIdx + 1).trim();
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`Invalid field name: ${name}`);
}
let zodType: string;
let tsType: string;
let enumValues: string[] | null = null;
if (type.startsWith('enum(') && type.endsWith(')')) {
enumValues = type
.slice(5, -1)
.split(',')
.map(v => v.trim())
.filter(Boolean);
if (enumValues.length === 0) throw new Error(`Empty enum: ${def}`);
zodType = `z.enum([${enumValues.map(v => `'${v}'`).join(', ')}])`;
tsType = enumValues.map(v => `'${v}'`).join(' | ');
} else {
const MAP: Record<string, { zod: string; ts: string }> = {
string: { zod: 'z.string().min(1)', ts: 'string' },
number: { zod: 'z.number()', ts: 'number' },
boolean: { zod: 'z.boolean()', ts: 'boolean' },
date: { zod: 'z.string().datetime()', ts: 'string' },
datetime: { zod: 'z.string().datetime()', ts: 'string' },
'string[]': { zod: 'z.array(z.string())', ts: 'string[]' },
'number[]': { zod: 'z.array(z.number())', ts: 'number[]' },
};
const mapping = MAP[type];
if (!mapping) throw new Error(`Unknown field type "${type}" in: ${def}`);
zodType = mapping.zod;
tsType = mapping.ts;
}
fields.push({ name, type, optional, zodType, tsType, enumValues });
}
return fields;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function pascal(s: string): string {
return s.replace(/(^|-)([a-z])/g, (_, __, c: string) => c.toUpperCase());
}
// ── Proxy Mode Templates ────────────────────────────────────────────────────
function genProxyListRoute(name: string, methods: string[], withHandler: boolean): string {
const hasGet = methods.includes('GET');
const hasPost = methods.includes('POST');
const handlerImport = withHandler
? `import { withErrorHandler } from '@/lib/api-handler';\n`
: '';
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
let out = `import { NextRequest, NextResponse } from 'next/server';
import { getAccessToken } from '@/lib/api-helpers';
${handlerImport}
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000';
`;
if (hasGet) {
out += `
export const GET = ${wrap(`async (req: NextRequest) => {
const token = await getAccessToken(req);
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const url = new URL(req.url);
const params = url.searchParams.toString();
const res = await fetch(\`\${BACKEND_URL}/api/${name}\${params ? '?' + params : ''}\`, {
headers: { Authorization: \`Bearer \${token}\` },
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}`)};
`;
}
if (hasPost) {
out += `
export const POST = ${wrap(`async (req: NextRequest) => {
const token = await getAccessToken(req);
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const res = await fetch(\`\${BACKEND_URL}/api/${name}\`, {
method: 'POST',
headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}`)};
`;
}
return out;
}
function genProxyDetailRoute(name: string, methods: string[], withHandler: boolean): string {
const hasGet = methods.includes('GET');
const hasPatch = methods.includes('PATCH');
const hasDelete = methods.includes('DELETE');
const handlerImport = withHandler
? `import { withErrorHandler } from '@/lib/api-handler';\n`
: '';
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
let out = `import { NextRequest, NextResponse } from 'next/server';
import { getAccessToken } from '@/lib/api-helpers';
${handlerImport}
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4000';
type RouteContext = { params: Promise<{ id: string }> };
`;
if (hasGet) {
out += `
export const GET = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
const token = await getAccessToken(req);
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
headers: { Authorization: \`Bearer \${token}\` },
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}`)};
`;
}
if (hasPatch) {
out += `
export const PATCH = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
const token = await getAccessToken(req);
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = await req.json();
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
method: 'PATCH',
headers: { Authorization: \`Bearer \${token}\`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}`)};
`;
}
if (hasDelete) {
out += `
export const DELETE = ${wrap(`async (req: NextRequest, { params }: RouteContext) => {
const token = await getAccessToken(req);
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const res = await fetch(\`\${BACKEND_URL}/api/${name}/\${id}\`, {
method: 'DELETE',
headers: { Authorization: \`Bearer \${token}\` },
});
if (res.status === 204) return new NextResponse(null, { status: 204 });
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}`)};
`;
}
return out;
}
// ── Direct Mode Templates ───────────────────────────────────────────────────
function genSchemaFile(name: string, fields: ParsedField[]): string {
const P = pascal(name);
const createFields = fields
.map(f => ` ${f.name}: ${f.zodType}${f.optional ? '.optional()' : ''},`)
.join('\n');
const updateFields = fields.map(f => ` ${f.name}: ${f.zodType}.optional(),`).join('\n');
return `import { z } from 'zod';
export const Create${P}Schema = z.object({
${createFields}
});
export const Update${P}Schema = z.object({
${updateFields}
});
export type Create${P}Input = z.infer<typeof Create${P}Schema>;
export type Update${P}Input = z.infer<typeof Update${P}Schema>;
`;
}
function genRepositoryFile(name: string, fields: ParsedField[]): string {
const P = pascal(name);
const docFields = fields.map(f => ` ${f.name}${f.optional ? '?' : ''}: ${f.tsType};`).join('\n');
return `import { randomUUID } from 'node:crypto';
import { getCosmosContainer, PRODUCT_ID } from '@/lib/datastore';
import type { Create${P}Input, Update${P}Input } from '@/lib/schemas/${name}';
export interface ${P}Doc {
id: string;
productId: string;
userId: string;
${docFields}
createdAt: string;
updatedAt: string;
}
function getContainer() {
return getCosmosContainer('${name}');
}
export async function list${P}(userId: string, limit = 50, offset = 0): Promise<${P}Doc[]> {
const { resources } = await getContainer()
.items.query<${P}Doc>({
query: 'SELECT * FROM c WHERE c.userId = @uid AND c.productId = @pid ORDER BY c.createdAt DESC OFFSET @off LIMIT @lim',
parameters: [
{ name: '@uid', value: userId },
{ name: '@pid', value: PRODUCT_ID },
{ name: '@off', value: offset },
{ name: '@lim', value: limit },
],
})
.fetchAll();
return resources;
}
export async function get${P}(id: string, userId: string): Promise<${P}Doc | null> {
try {
const { resource } = await getContainer().item(id, userId).read<${P}Doc>();
return resource ?? null;
} catch {
return null;
}
}
export async function create${P}(userId: string, input: Create${P}Input): Promise<${P}Doc> {
const now = new Date().toISOString();
const doc: ${P}Doc = {
id: \`${name.slice(0, 3)}_\${randomUUID()}\`,
productId: PRODUCT_ID,
userId,
...input,
createdAt: now,
updatedAt: now,
};
await getContainer().items.create(doc);
return doc;
}
export async function update${P}(id: string, userId: string, updates: Update${P}Input): Promise<${P}Doc | null> {
const existing = await get${P}(id, userId);
if (!existing) return null;
const updated: ${P}Doc = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
await getContainer().item(id, userId).replace(updated);
return updated;
}
export async function delete${P}(id: string, userId: string): Promise<boolean> {
try {
await getContainer().item(id, userId).delete();
return true;
} catch {
return false;
}
}
`;
}
function genDirectListRoute(
name: string,
fields: ParsedField[],
methods: string[],
withHandler: boolean
): string {
const P = pascal(name);
const hasGet = methods.includes('GET');
const hasPost = methods.includes('POST');
const handlerImport = withHandler
? `import { withErrorHandler } from '@/lib/api-handler';\n`
: '';
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
const imports: string[] = [];
if (hasGet) imports.push(`list${P}`);
if (hasPost) imports.push(`create${P}`);
let out = `import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
${handlerImport}`;
if (imports.length > 0) {
out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`;
}
if (hasPost) {
out += `import { Create${P}Schema } from '@/lib/schemas/${name}';\n`;
}
if (hasGet) {
out += `
export const GET = ${wrap(`async (req: NextRequest) => {
const user = await getCurrentUser(req.headers.get('authorization'));
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') ?? '50');
const offset = parseInt(url.searchParams.get('offset') ?? '0');
const items = await list${P}(user.id, limit, offset);
return NextResponse.json({ items });
}`)};
`;
}
if (hasPost) {
out += `
export const POST = ${wrap(`async (req: NextRequest) => {
const user = await getCurrentUser(req.headers.get('authorization'));
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const input = Create${P}Schema.parse(body);
const item = await create${P}(user.id, input);
return NextResponse.json(item, { status: 201 });
}`)};
`;
}
return out;
}
function genDirectDetailRoute(
name: string,
_fields: ParsedField[],
methods: string[],
withHandler: boolean
): string {
const P = pascal(name);
const hasGet = methods.includes('GET');
const hasPatch = methods.includes('PATCH');
const hasDelete = methods.includes('DELETE');
const handlerImport = withHandler
? `import { withErrorHandler } from '@/lib/api-handler';\n`
: '';
const wrap = (code: string) => (withHandler ? `withErrorHandler(${code})` : code);
const imports: string[] = [];
if (hasGet) imports.push(`get${P}`);
if (hasPatch) imports.push(`update${P}`);
if (hasDelete) imports.push(`delete${P}`);
let out = `import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
${handlerImport}`;
if (imports.length > 0) {
out += `import { ${imports.join(', ')} } from '@/lib/repositories/${name}';\n`;
}
if (hasPatch) {
out += `import { Update${P}Schema } from '@/lib/schemas/${name}';\n`;
}
out += `
type RouteContext = { params: Promise<{ id: string }> };
`;
if (hasGet) {
out += `
export const GET = ${wrap(`async (
req: NextRequest,
{ params }: RouteContext,
) => {
const user = await getCurrentUser(req.headers.get('authorization'));
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const item = await get${P}(id, user.id);
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(item);
}`)};
`;
}
if (hasPatch) {
out += `
export const PATCH = ${wrap(`async (
req: NextRequest,
{ params }: RouteContext,
) => {
const user = await getCurrentUser(req.headers.get('authorization'));
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const body = await req.json();
const updates = Update${P}Schema.parse(body);
const item = await update${P}(id, user.id, updates);
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(item);
}`)};
`;
}
if (hasDelete) {
out += `
export const DELETE = ${wrap(`async (
req: NextRequest,
{ params }: RouteContext,
) => {
const user = await getCurrentUser(req.headers.get('authorization'));
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const deleted = await delete${P}(id, user.id);
if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ success: true });
}`)};
`;
}
return out;
}
// ── Test Template ────────────────────────────────────────────────────────────
function genSchemaTest(name: string, fields: ParsedField[]): string {
const P = pascal(name);
const requiredFields = fields.filter(f => !f.optional);
function sampleValue(f: ParsedField): string {
if (f.enumValues) return `'${f.enumValues[0]}'`;
if (f.tsType === 'string') return `'test'`;
if (f.tsType === 'number') return '42';
if (f.tsType === 'boolean') return 'true';
return `'2026-01-01T00:00:00.000Z'`;
}
const validPayload = requiredFields.map(f => ` ${f.name}: ${sampleValue(f)},`).join('\n');
return `import { describe, it, expect } from 'vitest';
import { Create${P}Schema, Update${P}Schema } from './schemas/${name}';
describe('Create${P}Schema', () => {
it('accepts valid input', () => {
const result = Create${P}Schema.parse({
${validPayload}
});
expect(result).toBeDefined();
});
it('rejects empty object', () => {
expect(() => Create${P}Schema.parse({})).toThrow();
});
});
describe('Update${P}Schema', () => {
it('accepts empty object (all optional)', () => {
const result = Update${P}Schema.parse({});
expect(result).toEqual({});
});
it('accepts partial update', () => {
const result = Update${P}Schema.parse({ ${requiredFields[0]?.name ?? 'id'}: ${sampleValue(requiredFields[0] ?? fields[0])} });
expect(result).toBeDefined();
});
});
`;
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const { name, fields, target, mode, methods, withHandler, dryRun } = parseArgs();
console.log(`\n🚀 Generating API routes: ${name}`);
console.log(` Mode: ${mode}`);
console.log(` Methods: ${methods.join(', ')}`);
console.log(` Target: ${target}`);
if (fields) console.log(` Fields: ${fields}`);
if (dryRun) console.log(' ⚠️ DRY RUN — no files will be written\n');
const parsedFields = parseFields(fields);
// Determine which files to generate
const files: { path: string; content: string }[] = [];
if (mode === 'proxy') {
files.push({
path: `app/api/${name}/route.ts`,
content: genProxyListRoute(name, methods, withHandler),
});
if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) {
files.push({
path: `app/api/${name}/[id]/route.ts`,
content: genProxyDetailRoute(name, methods, withHandler),
});
}
} else {
// Direct mode — also generate schema + repository
if (parsedFields.length === 0) {
console.error('Error: --fields is required in direct mode');
process.exit(1);
}
files.push({
path: `lib/schemas/${name}.ts`,
content: genSchemaFile(name, parsedFields),
});
files.push({
path: `lib/repositories/${name}.ts`,
content: genRepositoryFile(name, parsedFields),
});
files.push({
path: `app/api/${name}/route.ts`,
content: genDirectListRoute(name, parsedFields, methods, withHandler),
});
if (methods.some(m => ['GET', 'PATCH', 'DELETE'].includes(m))) {
files.push({
path: `app/api/${name}/[id]/route.ts`,
content: genDirectDetailRoute(name, parsedFields, methods, withHandler),
});
}
files.push({
path: `lib/__tests__/${name}.test.ts`,
content: genSchemaTest(name, parsedFields),
});
}
if (dryRun) {
console.log('📄 Generated files:\n');
for (const file of files) {
console.log(`── ${file.path} ──────────────────────────────────────`);
console.log(file.content);
}
console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`);
return;
}
// Write files
for (const file of files) {
const fullPath = path.join(target, file.path);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
// Check if file already exists
try {
await fs.access(fullPath);
console.log(` ⚠️ SKIP ${file.path} (already exists)`);
continue;
} catch {
// Good — doesn't exist
}
await fs.writeFile(fullPath, file.content, 'utf-8');
console.log(`${file.path}`);
}
console.log(`\n✨ API routes generated for "${name}".`);
if (mode === 'direct') {
console.log(`\nPrerequisites (if not already present):`);
console.log(` - lib/auth-server.ts — getCurrentUser(authHeader) function`);
console.log(` - lib/api-handler.ts — withErrorHandler HOF`);
console.log(` - lib/datastore.ts — getCosmosContainer + PRODUCT_ID`);
console.log(` - zod installed — npm install zod`);
} else {
console.log(`\nPrerequisites (if not already present):`);
console.log(` - lib/api-helpers.ts — getAccessToken(req) function`);
console.log(` - lib/api-handler.ts — withErrorHandler HOF`);
console.log(` - NEXT_PUBLIC_BACKEND_URL env var (or defaults to localhost:4000)`);
}
}
main().catch(err => {
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});

View File

@ -0,0 +1,9 @@
/**
* @bytelyst/create-app CLI tools for scaffolding ByteLyst product repos.
*
* Generators:
* - api-routes.ts Next.js App Router API route generator
* - agents-md.ts AGENTS.md auto-generator from product.json + repo scan
*/
export {};

View File

@ -0,0 +1,34 @@
/**
* Simple template engine for scaffolding.
* Supports {{VARIABLE}} replacement and {{#IF FEATURE}}...{{/IF FEATURE}} conditional blocks.
*/
export type TemplateVars = Record<string, string | boolean | number>;
/**
* Replace {{VARIABLE}} placeholders and process {{#IF FEATURE}}...{{/IF FEATURE}} blocks.
*/
export function renderTemplate(template: string, vars: TemplateVars): string {
let result = template;
// Process conditional blocks: {{#IF KEY}}...{{/IF KEY}}
const ifRegex = /\{\{#IF (\w+)\}\}([\s\S]*?)\{\{\/IF \1\}\}/g;
result = result.replace(ifRegex, (_, key: string, content: string) => {
return vars[key] ? content : '';
});
// Process negative conditional blocks: {{#UNLESS KEY}}...{{/UNLESS KEY}}
const unlessRegex = /\{\{#UNLESS (\w+)\}\}([\s\S]*?)\{\{\/UNLESS \1\}\}/g;
result = result.replace(unlessRegex, (_, key: string, content: string) => {
return !vars[key] ? content : '';
});
// Replace {{VARIABLE}} placeholders
result = result.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
const val = vars[key];
if (val === undefined) return match;
return String(val);
});
return result;
}

View File

@ -0,0 +1,457 @@
/**
* Inline templates for product repo scaffolding.
* Each template uses {{VARIABLE}} and {{#IF FEATURE}}...{{/IF FEATURE}} syntax.
*/
// ── product.json ─────────────────────────────────────────────────────────────
export const PRODUCT_JSON = `{
"productId": "{{PRODUCT_ID}}",
"displayName": "{{DISPLAY_NAME}}",
"tagline": "{{TAGLINE}}",
"domain": "{{DOMAIN}}",
"backendPort": {{BACKEND_PORT}},
"primarySurface": "{{PRIMARY_SURFACE}}",
"bundleIds": {
"web": "{{DOMAIN}}"{{#IF HAS_IOS}},
"ios": "com.bytelyst.{{PRODUCT_ID}}"{{/IF HAS_IOS}}{{#IF HAS_ANDROID}},
"android": "com.{{PRODUCT_ID}}.app"{{/IF HAS_ANDROID}}
},
"appStore": {
"category": "Productivity",
"privacyUrl": "https://{{DOMAIN}}/privacy",
"termsUrl": "https://{{DOMAIN}}/terms",
"supportUrl": "https://{{DOMAIN}}/support"
},
"version": "0.1.0"
}
`;
// ── .gitignore ───────────────────────────────────────────────────────────────
export const GITIGNORE = `node_modules/
dist/
.next/
.env
.env.local
*.log
.DS_Store
coverage/
`;
// ── .env.example ─────────────────────────────────────────────────────────────
export const ENV_EXAMPLE = `# {{DISPLAY_NAME}} environment variables
NODE_ENV=development
{{#IF HAS_BACKEND}}
# Backend
PORT={{BACKEND_PORT}}
HOST=0.0.0.0
JWT_SECRET=dev-secret-change-me
DB_PROVIDER=memory
COSMOS_ENDPOINT=
COSMOS_KEY=
COSMOS_DATABASE=bytelyst
PLATFORM_SERVICE_URL=http://localhost:4003
{{/IF HAS_BACKEND}}
{{#IF HAS_WEB}}
# Web
NEXT_PUBLIC_BACKEND_URL=http://localhost:{{BACKEND_PORT}}
{{/IF HAS_WEB}}
`;
// ── README.md ────────────────────────────────────────────────────────────────
export const README = `# {{DISPLAY_NAME}}
> {{TAGLINE}}
## Quick Start
\`\`\`bash
{{#IF HAS_BACKEND}}# Backend
cd backend && npm install && npm run dev
{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}# Web
cd web && npm install && npm run dev
{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}# Mobile
cd mobile && npm install && npm start
{{/IF HAS_MOBILE}}\`\`\`
## Architecture
| Layer | Technology |
|-------|-----------|
{{#IF HAS_BACKEND}}| Backend | Fastify 5 + TypeScript ESM (port {{BACKEND_PORT}}) |
{{/IF HAS_BACKEND}}{{#IF HAS_WEB}}| Web | Next.js 16 (App Router) + React 19 |
{{/IF HAS_WEB}}{{#IF HAS_MOBILE}}| Mobile | React Native (Expo) |
{{/IF HAS_MOBILE}}| Platform | platform-service (port 4003) |
| Database | Azure Cosmos DB (\`productId: "{{PRODUCT_ID}}"\`) |
## Product Identity
- **Product ID:** \`{{PRODUCT_ID}}\`
- **Domain:** {{DOMAIN}}
- **Backend Port:** {{BACKEND_PORT}}
See [AGENTS.md](AGENTS.md) for AI agent instructions.
`;
// ── Backend templates ────────────────────────────────────────────────────────
export const BACKEND_PACKAGE_JSON = `{
"name": "@{{PRODUCT_ID}}/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bytelyst/config": "file:../../learning_ai_common_plat/packages/config",
"@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos",
"@bytelyst/datastore": "file:../../learning_ai_common_plat/packages/datastore",
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
"fastify": "^5.3.3",
"jose": "^6.0.11",
"zod": "^3.24.4"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}
`;
export const BACKEND_TSCONFIG = `{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"],
"exclude": ["dist", "src/**/*.test.ts"]
}
`;
export const BACKEND_CONFIG = `import { z } from 'zod';
import { PRODUCT_ID } from './product-config.js';
const envSchema = z.object({
PORT: z.coerce.number().default({{BACKEND_PORT}}),
HOST: z.string().default('0.0.0.0'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CORS_ORIGIN: z.string().optional(),
SERVICE_NAME: z.string().default('{{PRODUCT_ID}}-backend'),
COSMOS_ENDPOINT: z.string().optional(),
COSMOS_KEY: z.string().optional(),
COSMOS_DATABASE: z.string().default('bytelyst'),
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'),
PRODUCT_ID: z.string().default(PRODUCT_ID),
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
});
export const config = envSchema.parse(process.env);
`;
export const BACKEND_PRODUCT_CONFIG = `import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const raw = readFileSync(join(__dirname, '../../../shared/product.json'), 'utf-8');
const manifest = JSON.parse(raw);
export const PRODUCT_ID: string = manifest.productId;
`;
export const BACKEND_AUTH = `import { jwtVerify, createRemoteJWKSet } from 'jose';
import type { FastifyRequest } from 'fastify';
import { config } from './config.js';
const secret = new TextEncoder().encode(config.JWT_SECRET);
export interface JwtPayload {
sub: string;
email?: string;
role?: string;
}
export async function verifyToken(token: string): Promise<JwtPayload | null> {
try {
const { payload } = await jwtVerify(token, secret);
return payload as unknown as JwtPayload;
} catch {
return null;
}
}
export function extractToken(req: FastifyRequest): string | null {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return null;
return auth.slice(7);
}
`;
export const BACKEND_REQUEST_CONTEXT = `import type { FastifyRequest } from 'fastify';
import { verifyToken, extractToken, type JwtPayload } from './auth.js';
import { config } from './config.js';
export async function getUserPayload(req: FastifyRequest): Promise<JwtPayload | null> {
const token = extractToken(req);
if (!token) return null;
return verifyToken(token);
}
export function getUserId(req: FastifyRequest & { user?: JwtPayload }): string {
if (!req.user?.sub) throw new Error('Unauthenticated');
return req.user.sub;
}
export function getRequestProductId(_req: FastifyRequest): string {
return config.PRODUCT_ID;
}
`;
export const BACKEND_ERRORS = `export { BadRequestError, NotFoundError, ConflictError, ForbiddenError } from '@bytelyst/errors';
`;
export const BACKEND_DATASTORE = `import { config } from './config.js';
const collections = new Map<string, Map<string, unknown>>();
export function getCollection<T = unknown>(name: string): Map<string, T> {
if (!collections.has(name)) {
collections.set(name, new Map());
}
return collections.get(name) as Map<string, T>;
}
export const PRODUCT_ID = config.PRODUCT_ID;
export const DB_PROVIDER = config.DB_PROVIDER;
`;
export const BACKEND_SERVER = `import Fastify from 'fastify';
import { config } from './lib/config.js';
const app = Fastify({
logger: {
level: config.NODE_ENV === 'test' ? 'silent' : 'info',
},
});
// CORS
if (config.CORS_ORIGIN) {
app.addHook('onRequest', async (request, reply) => {
reply.header('Access-Control-Allow-Origin', config.CORS_ORIGIN);
reply.header('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (request.method === 'OPTIONS') {
reply.status(204).send();
}
});
}
// Health check
app.get('/health', async () => ({
status: 'ok',
service: config.SERVICE_NAME,
productId: config.PRODUCT_ID,
}));
// TODO: Register your route modules here
// import { routes as exampleRoutes } from './modules/example/routes.js';
// app.register(exampleRoutes, { prefix: '/api' });
async function start() {
try {
await app.listen({ port: config.PORT, host: config.HOST });
app.log.info(\`{{DISPLAY_NAME}} backend listening on port \${config.PORT}\`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();
export { app };
`;
// ── Web templates ────────────────────────────────────────────────────────────
export const WEB_PACKAGE_JSON = `{
"name": "@{{PRODUCT_ID}}/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.16.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"typescript": "^5.7.3"
}
}
`;
export const WEB_TSCONFIG = `{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
`;
export const WEB_NEXT_CONFIG = `import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
`;
export const WEB_LAYOUT = `import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '{{DISPLAY_NAME}}',
description: '{{TAGLINE}}',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`;
export const WEB_PAGE = `export default function Home() {
return (
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto', fontFamily: 'system-ui' }}>
<h1>{{DISPLAY_NAME}}</h1>
<p>{{TAGLINE}}</p>
<p>Edit <code>src/app/page.tsx</code> to get started.</p>
</main>
);
}
`;
export const WEB_PRODUCT_CONFIG = `const manifest = {
productId: '{{PRODUCT_ID}}',
displayName: '{{DISPLAY_NAME}}',
domain: '{{DOMAIN}}',
backendPort: {{BACKEND_PORT}},
};
export const PRODUCT_ID = manifest.productId;
export function getBackendURL(): string {
return process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:{{BACKEND_PORT}}';
}
export default manifest;
`;
// ── Mobile (Expo) templates ──────────────────────────────────────────────────
export const MOBILE_PACKAGE_JSON = `{
"name": "@{{PRODUCT_ID}}/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"expo": "~55.0.0",
"expo-router": "~5.0.0",
"react": "^19.0.0",
"react-native": "^0.79.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"typescript": "^5.7.3"
}
}
`;
export const MOBILE_APP_JSON = `{
"expo": {
"name": "{{DISPLAY_NAME}}",
"slug": "{{PRODUCT_ID}}",
"version": "1.0.0",
"scheme": "{{PRODUCT_ID}}",
"platforms": ["ios", "android"],
"ios": {
"bundleIdentifier": "com.bytelyst.{{PRODUCT_ID}}"
},
"android": {
"package": "com.{{PRODUCT_ID}}.app"
}
}
}
`;
export const MOBILE_INDEX = `import { View, Text, StyleSheet } from 'react-native';
export default function Home() {
return (
<View style={styles.container}>
<Text style={styles.title}>{{DISPLAY_NAME}}</Text>
<Text style={styles.subtitle}>{{TAGLINE}}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 8 },
subtitle: { fontSize: 16, color: '#666' },
});
`;

View File

@ -0,0 +1,315 @@
#!/usr/bin/env node
/**
* CLI Scaffolder generates a fully wired ByteLyst product repo.
*
* Usage:
* npx tsx scaffolder.ts # Interactive prompts
* npx tsx scaffolder.ts --from product.json # From existing manifest
* npx tsx scaffolder.ts --from product.json --dry-run # Preview
*
* @module @bytelyst/create-app/scaffolder
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { renderTemplate, type TemplateVars } from './lib/template-engine.js';
import * as T from './lib/templates.js';
// ── Types ────────────────────────────────────────────────────────────────────
interface ProductManifest {
productId: string;
displayName: string;
tagline: string;
domain: string;
backendPort: number;
primarySurface: string;
platforms: ('web' | 'mobile' | 'ios' | 'android')[];
features: string[];
}
interface CliOptions {
from: string | null;
outDir: string | null;
dryRun: boolean;
}
// ── CLI Arg Parsing ──────────────────────────────────────────────────────────
function parseCliArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = { from: null, outDir: null, dryRun: false };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--from' || arg === '-f') options.from = args[++i];
else if (arg === '--out' || arg === '-o') options.outDir = args[++i];
else if (arg === '--dry-run' || arg === '-d') options.dryRun = true;
else if (arg === '--help' || arg === '-h') {
showHelp();
process.exit(0);
}
}
return options;
}
function showHelp(): void {
// eslint-disable-next-line no-console
console.log(`
@bytelyst/create-app Product Repo Scaffolder
Generates a fully wired ByteLyst product repo with backend, web, and/or mobile.
Usage:
npx tsx scaffolder.ts # Interactive prompts
npx tsx scaffolder.ts --from product.json # From existing manifest
npx tsx scaffolder.ts --from product.json -d # Dry run
Options:
--from, -f Path to existing product.json (skip prompts)
--out, -o Output directory (default: ./<productId>)
--dry-run, -d Preview files without writing
--help, -h Show this help
Interactive Prompts:
1. Product name + ID + tagline + domain
2. Backend port
3. Platform selection: web, mobile (Expo), iOS, Android
4. Feature selection: auth, billing, telemetry, flags, sync, push
Output:
<productId>/
shared/product.json
backend/ (if selected)
web/ (if selected)
mobile/ (if selected)
.gitignore
.env.example
README.md
AGENTS.md
`);
}
// ── Interactive Prompts ──────────────────────────────────────────────────────
function createPrompt(): (question: string) => Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return (question: string) =>
new Promise(resolve => {
rl.question(question, answer => {
resolve(answer.trim());
});
});
}
async function gatherManifestInteractively(): Promise<ProductManifest> {
const ask = createPrompt();
// eslint-disable-next-line no-console
console.log('\n📦 ByteLyst Product Scaffolder\n');
const displayName = (await ask('Product name (e.g., FlowMonk): ')) || 'MyApp';
const defaultId = displayName.toLowerCase().replace(/[^a-z0-9]/g, '');
const productId = (await ask(`Product ID [${defaultId}]: `)) || defaultId;
const tagline = (await ask('Tagline: ')) || `${displayName} — a ByteLyst product`;
const defaultDomain = `${productId}.app`;
const domain = (await ask(`Domain [${defaultDomain}]: `)) || defaultDomain;
const backendPort = parseInt(await ask('Backend port [4020]: ')) || 4020;
// eslint-disable-next-line no-console
console.log('\nPlatforms (comma-separated: web, mobile, ios, android)');
const platformStr = (await ask('Platforms [web]: ')) || 'web';
const platforms = platformStr
.split(',')
.map(p => p.trim().toLowerCase()) as ProductManifest['platforms'];
const primarySurface = platforms.includes('web') ? 'web' : platforms[0];
// eslint-disable-next-line no-console
console.log('\nFeatures (comma-separated: auth, billing, telemetry, flags, sync, push)');
const featureStr = (await ask('Features [auth,telemetry,flags]: ')) || 'auth,telemetry,flags';
const features = featureStr.split(',').map(f => f.trim().toLowerCase());
// Close readline
process.stdin.unref();
return {
productId,
displayName,
tagline,
domain,
backendPort,
primarySurface,
platforms,
features,
};
}
async function loadManifestFromFile(filePath: string): Promise<ProductManifest> {
const raw = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(raw);
return {
productId: data.productId || 'myapp',
displayName: data.displayName || data.productId || 'MyApp',
tagline: data.tagline || `${data.displayName} — a ByteLyst product`,
domain: data.domain || `${data.productId}.app`,
backendPort: data.backendPort || 4020,
primarySurface: data.primarySurface || 'web',
platforms: data.platforms || ['web'],
features: data.features || ['auth', 'telemetry', 'flags'],
};
}
// ── File Generator ───────────────────────────────────────────────────────────
interface GeneratedFile {
path: string;
content: string;
}
function generateFiles(manifest: ProductManifest): GeneratedFile[] {
const vars: TemplateVars = {
PRODUCT_ID: manifest.productId,
DISPLAY_NAME: manifest.displayName,
TAGLINE: manifest.tagline,
DOMAIN: manifest.domain,
BACKEND_PORT: manifest.backendPort,
PRIMARY_SURFACE: manifest.primarySurface,
HAS_BACKEND: true, // Always generate backend
HAS_WEB: manifest.platforms.includes('web'),
HAS_MOBILE: manifest.platforms.includes('mobile'),
HAS_IOS: manifest.platforms.includes('ios'),
HAS_ANDROID: manifest.platforms.includes('android'),
HAS_AUTH: manifest.features.includes('auth'),
HAS_BILLING: manifest.features.includes('billing'),
HAS_TELEMETRY: manifest.features.includes('telemetry'),
HAS_FLAGS: manifest.features.includes('flags'),
HAS_SYNC: manifest.features.includes('sync'),
HAS_PUSH: manifest.features.includes('push'),
};
const render = (tmpl: string) => renderTemplate(tmpl, vars);
const files: GeneratedFile[] = [];
// ── Root files
files.push({ path: 'shared/product.json', content: render(T.PRODUCT_JSON) });
files.push({ path: '.gitignore', content: render(T.GITIGNORE) });
files.push({ path: '.env.example', content: render(T.ENV_EXAMPLE) });
files.push({ path: 'README.md', content: render(T.README) });
// ── Backend (always generated)
files.push({ path: 'backend/package.json', content: render(T.BACKEND_PACKAGE_JSON) });
files.push({ path: 'backend/tsconfig.json', content: render(T.BACKEND_TSCONFIG) });
files.push({ path: 'backend/src/lib/config.ts', content: render(T.BACKEND_CONFIG) });
files.push({
path: 'backend/src/lib/product-config.ts',
content: render(T.BACKEND_PRODUCT_CONFIG),
});
files.push({ path: 'backend/src/lib/auth.ts', content: render(T.BACKEND_AUTH) });
files.push({
path: 'backend/src/lib/request-context.ts',
content: render(T.BACKEND_REQUEST_CONTEXT),
});
files.push({ path: 'backend/src/lib/errors.ts', content: render(T.BACKEND_ERRORS) });
files.push({ path: 'backend/src/lib/datastore.ts', content: render(T.BACKEND_DATASTORE) });
files.push({ path: 'backend/src/server.ts', content: render(T.BACKEND_SERVER) });
// ── Web (if selected)
if (manifest.platforms.includes('web')) {
files.push({ path: 'web/package.json', content: render(T.WEB_PACKAGE_JSON) });
files.push({ path: 'web/tsconfig.json', content: render(T.WEB_TSCONFIG) });
files.push({ path: 'web/next.config.ts', content: render(T.WEB_NEXT_CONFIG) });
files.push({ path: 'web/src/app/layout.tsx', content: render(T.WEB_LAYOUT) });
files.push({ path: 'web/src/app/page.tsx', content: render(T.WEB_PAGE) });
files.push({ path: 'web/src/lib/product-config.ts', content: render(T.WEB_PRODUCT_CONFIG) });
}
// ── Mobile (if selected)
if (manifest.platforms.includes('mobile')) {
files.push({ path: 'mobile/package.json', content: render(T.MOBILE_PACKAGE_JSON) });
files.push({ path: 'mobile/app.json', content: render(T.MOBILE_APP_JSON) });
files.push({ path: 'mobile/src/app/index.tsx', content: render(T.MOBILE_INDEX) });
}
return files;
}
// ── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const cliOpts = parseCliArgs();
const manifest = cliOpts.from
? await loadManifestFromFile(cliOpts.from)
: await gatherManifestInteractively();
const outDir = cliOpts.outDir || manifest.productId;
const outPath = path.resolve(outDir);
// eslint-disable-next-line no-console
console.log(`\n🚀 Scaffolding ${manifest.displayName}`);
// eslint-disable-next-line no-console
console.log(` Product ID: ${manifest.productId}`);
// eslint-disable-next-line no-console
console.log(` Output: ${outPath}`);
// eslint-disable-next-line no-console
console.log(` Platforms: ${manifest.platforms.join(', ')}`);
// eslint-disable-next-line no-console
console.log(` Features: ${manifest.features.join(', ')}`);
if (cliOpts.dryRun) {
// eslint-disable-next-line no-console
console.log(' ⚠️ DRY RUN\n');
}
const files = generateFiles(manifest);
if (cliOpts.dryRun) {
// eslint-disable-next-line no-console
console.log(`📄 ${files.length} files would be generated:\n`);
for (const file of files) {
// eslint-disable-next-line no-console
console.log(`── ${file.path} ──────────────────────────────────────`);
// eslint-disable-next-line no-console
console.log(file.content);
}
// eslint-disable-next-line no-console
console.log(`\n✨ Dry run complete. Re-run without --dry-run to write files.`);
return;
}
// Write files
for (const file of files) {
const fullPath = path.join(outPath, file.path);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, file.content, 'utf-8');
// eslint-disable-next-line no-console
console.log(`${file.path}`);
}
// eslint-disable-next-line no-console
console.log(`\n✨ ${manifest.displayName} scaffolded at ${outPath}`);
// eslint-disable-next-line no-console
console.log(`\nNext steps:`);
// eslint-disable-next-line no-console
console.log(` cd ${outDir}/backend && npm install && npm run dev`);
if (manifest.platforms.includes('web')) {
// eslint-disable-next-line no-console
console.log(` cd ${outDir}/web && npm install && npm run dev`);
}
}
// Export for testing
export { generateFiles, renderTemplate, type ProductManifest, type GeneratedFile };
main().catch(err => {
// eslint-disable-next-line no-console
console.error('❌ Error:', err instanceof Error ? err.message : String(err));
process.exit(1);
});

View File

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

View File

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

View File

@ -0,0 +1,39 @@
{
"name": "@bytelyst/dashboard-components",
"version": "0.1.5",
"description": "Shared React components for ByteLyst dashboards",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
},
"publishConfig": {
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
}
}

View File

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}
export function EmptyState({
icon,
title,
description,
action,
className = '',
}: EmptyStateProps): ReactNode {
return (
<div
className={`flex flex-col items-center justify-center min-h-[300px] p-8 text-center ${className}`}
>
{icon && (
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--color-muted, #f3f4f6)' }}
>
{icon}
</div>
)}
<h3
className="text-lg font-medium mb-2"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h3>
<p className="max-w-sm mb-6" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
{description}
</p>
{action && (
<button
onClick={action.onClick}
className="px-4 py-2 rounded-lg transition-colors text-white"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{action.label}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
export interface ErrorPageProps {
title?: string;
message?: string;
onRetry?: () => void;
className?: string;
}
export function ErrorPage({
title = 'Something went wrong',
message = 'An unexpected error occurred. Please try again.',
onRetry,
className = '',
}: ErrorPageProps): ReactNode {
return (
<div className={`flex flex-col items-center justify-center min-h-[400px] p-8 ${className}`}>
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
style={{ backgroundColor: 'var(--color-destructive-muted, #fef2f2)' }}
>
<svg
className="w-8 h-8"
style={{ color: 'var(--color-destructive, #ef4444)' }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2
className="text-xl font-semibold mb-2"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h2>
<p
className="text-center max-w-md mb-6"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
>
{message}
</p>
{onRetry && (
<button
onClick={onRetry}
className="px-4 py-2 rounded-lg transition-colors text-white"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
Try Again
</button>
)}
</div>
);
}

View File

@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
export interface LoadingSkeletonProps {
rows?: number;
className?: string;
}
export function LoadingSkeleton({ rows = 3, className = '' }: LoadingSkeletonProps): ReactNode {
return (
<div className={`space-y-3 ${className}`} role="status" aria-label="Loading content">
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="h-12 rounded animate-pulse"
style={{ backgroundColor: 'var(--color-muted, #e5e7eb)' }}
/>
))}
</div>
);
}

View File

@ -0,0 +1,40 @@
import type { ReactNode } from 'react';
export interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps): ReactNode {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`${sizeClasses[size]} ${className}`} role="status" aria-label="Loading">
<svg
className="animate-spin"
style={{ color: 'var(--color-primary, currentColor)' }}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
}

View File

@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
export interface NotFoundPageProps {
title?: string;
message?: string;
statusCode?: string;
backLabel?: string;
backHref?: string;
onBack?: () => void;
className?: string;
}
export function NotFoundPage({
title = 'Page not found',
message = "The page you're looking for doesn't exist or has been moved.",
statusCode = '404',
backLabel = 'Go Back',
backHref,
onBack,
className = '',
}: NotFoundPageProps): ReactNode {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${className}`}>
<div className="mx-auto max-w-md text-center">
<div
className="mb-4 text-6xl font-bold"
style={{ color: 'var(--color-muted-foreground, #9ca3af)' }}
>
{statusCode}
</div>
<h2
className="mb-2 text-xl font-semibold"
style={{ color: 'var(--color-foreground, #111827)' }}
>
{title}
</h2>
<p className="mb-6 text-sm" style={{ color: 'var(--color-muted-foreground, #6b7280)' }}>
{message}
</p>
{(onBack || backHref) &&
(backHref ? (
<a
href={backHref}
className="inline-block rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{backLabel}
</a>
) : (
<button
onClick={onBack}
className="rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ backgroundColor: 'var(--color-primary, #2563eb)' }}
>
{backLabel}
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
export interface Breadcrumb {
label: string;
href?: string;
}
export interface PageHeaderProps {
title: string;
breadcrumbs?: Breadcrumb[];
actions?: ReactNode;
className?: string;
}
export function PageHeader({
title,
breadcrumbs,
actions,
className = '',
}: PageHeaderProps): ReactNode {
return (
<div className={`flex items-center justify-between mb-6 ${className}`}>
<div>
{breadcrumbs && breadcrumbs.length > 0 && (
<nav
className="flex items-center space-x-2 text-sm mb-2"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
aria-label="Breadcrumb"
>
{breadcrumbs.map((crumb, index) => (
<span key={index} className="flex items-center">
{index > 0 && <span className="mx-2">/</span>}
{crumb.href ? (
<a
href={crumb.href}
className="hover:opacity-80 transition-opacity"
style={{ color: 'var(--color-muted-foreground, #6b7280)' }}
>
{crumb.label}
</a>
) : (
<span>{crumb.label}</span>
)}
</span>
))}
</nav>
)}
<h1 className="text-2xl font-bold" style={{ color: 'var(--color-foreground, #111827)' }}>
{title}
</h1>
</div>
{actions && <div className="flex items-center space-x-3">{actions}</div>}
</div>
);
}

View File

@ -0,0 +1,254 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { LoadingSpinner } from './LoadingSpinner.js';
import { LoadingSkeleton } from './LoadingSkeleton.js';
import { EmptyState } from './EmptyState.js';
import { PageHeader } from './PageHeader.js';
import { ErrorPage } from './ErrorPage.js';
import { NotFoundPage } from './NotFoundPage.js';
describe('LoadingSpinner', () => {
it('renders with default size', () => {
render(<LoadingSpinner />);
const status = screen.getByRole('status');
expect(status).toBeDefined();
expect(status.className).toContain('w-8 h-8');
});
it('renders with small size', () => {
render(<LoadingSpinner size="sm" />);
const status = screen.getByRole('status');
expect(status.className).toContain('w-4 h-4');
});
it('renders with large size', () => {
render(<LoadingSpinner size="lg" />);
const status = screen.getByRole('status');
expect(status.className).toContain('w-12 h-12');
});
it('applies custom className', () => {
render(<LoadingSpinner className="mt-4" />);
const status = screen.getByRole('status');
expect(status.className).toContain('mt-4');
});
it('renders SVG spinner element', () => {
render(<LoadingSpinner />);
const svg = screen.getByRole('status').querySelector('svg');
expect(svg).toBeDefined();
expect(svg!.classList.contains('animate-spin')).toBe(true);
});
});
describe('LoadingSkeleton', () => {
it('renders default 3 rows', () => {
render(<LoadingSkeleton />);
const container = screen.getByRole('status');
const rows = container.querySelectorAll('.animate-pulse');
expect(rows.length).toBe(3);
});
it('renders custom number of rows', () => {
render(<LoadingSkeleton rows={5} />);
const container = screen.getByRole('status');
const rows = container.querySelectorAll('.animate-pulse');
expect(rows.length).toBe(5);
});
it('applies custom className', () => {
render(<LoadingSkeleton className="my-8" />);
const container = screen.getByRole('status');
expect(container.className).toContain('my-8');
});
it('renders pulse-animated skeleton rows', () => {
render(<LoadingSkeleton rows={1} />);
const row = screen.getByRole('status').querySelector('.animate-pulse');
expect(row).toBeDefined();
expect(row!.classList.contains('rounded')).toBe(true);
});
});
describe('EmptyState', () => {
it('renders title and description', () => {
render(<EmptyState title="No items" description="Create your first item." />);
expect(screen.getByText('No items')).toBeDefined();
expect(screen.getByText('Create your first item.')).toBeDefined();
});
it('renders icon when provided', () => {
render(
<EmptyState
title="No items"
description="Create one."
icon={<span data-testid="icon">X</span>}
/>
);
expect(screen.getByTestId('icon')).toBeDefined();
});
it('does not render icon container when not provided', () => {
const { container } = render(<EmptyState title="No items" description="Create one." />);
const iconWrapper = container.querySelector('.w-16.h-16');
expect(iconWrapper).toBeNull();
});
it('renders action button and handles click', () => {
const onClick = vi.fn();
render(
<EmptyState
title="No items"
description="Create one."
action={{ label: 'Create', onClick }}
/>
);
const button = screen.getByText('Create');
expect(button).toBeDefined();
fireEvent.click(button);
expect(onClick).toHaveBeenCalledOnce();
});
it('does not render action button when not provided', () => {
const { container } = render(<EmptyState title="No items" description="Create one." />);
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
it('renders with theme-aware structure', () => {
const { container } = render(<EmptyState title="Test" description="Desc" />);
const heading = container.querySelector('h3');
expect(heading).toBeDefined();
expect(heading!.textContent).toBe('Test');
const desc = container.querySelector('p');
expect(desc).toBeDefined();
expect(desc!.textContent).toBe('Desc');
});
});
describe('PageHeader', () => {
it('renders title', () => {
render(<PageHeader title="Dashboard" />);
expect(screen.getByText('Dashboard')).toBeDefined();
});
it('renders breadcrumbs', () => {
render(
<PageHeader title="Users" breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Users' }]} />
);
expect(screen.getByText('Home')).toBeDefined();
expect(screen.getByLabelText('Breadcrumb')).toBeDefined();
});
it('renders breadcrumb links with href', () => {
render(
<PageHeader
title="Detail"
breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Detail' }]}
/>
);
const link = screen.getByText('Home');
expect(link.tagName).toBe('A');
expect(link.getAttribute('href')).toBe('/');
});
it('renders breadcrumb text without href', () => {
render(<PageHeader title="Detail" breadcrumbs={[{ label: 'Current' }]} />);
const text = screen.getByText('Current');
expect(text.tagName).toBe('SPAN');
});
it('renders actions', () => {
render(<PageHeader title="Test" actions={<button data-testid="action-btn">Action</button>} />);
expect(screen.getByTestId('action-btn')).toBeDefined();
});
it('does not render breadcrumb nav when empty', () => {
const { container } = render(<PageHeader title="Simple" />);
const nav = container.querySelector('nav');
expect(nav).toBeNull();
});
});
describe('ErrorPage', () => {
it('renders with default props', () => {
render(<ErrorPage />);
expect(screen.getByText('Something went wrong')).toBeDefined();
expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeDefined();
});
it('renders custom title and message', () => {
render(<ErrorPage title="Server Error" message="The server is down." />);
expect(screen.getByText('Server Error')).toBeDefined();
expect(screen.getByText('The server is down.')).toBeDefined();
});
it('renders retry button and handles click', () => {
const onRetry = vi.fn();
render(<ErrorPage onRetry={onRetry} />);
const button = screen.getByText('Try Again');
expect(button).toBeDefined();
fireEvent.click(button);
expect(onRetry).toHaveBeenCalledOnce();
});
it('does not render retry button when not provided', () => {
const { container } = render(<ErrorPage />);
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(0);
});
it('renders error icon and semantic structure', () => {
const { container } = render(<ErrorPage />);
const iconContainer = container.querySelector('.w-16');
expect(iconContainer).toBeDefined();
const svg = iconContainer!.querySelector('svg');
expect(svg).toBeDefined();
const heading = container.querySelector('h2');
expect(heading).toBeDefined();
expect(heading!.textContent).toBe('Something went wrong');
});
});
describe('NotFoundPage', () => {
it('renders with default props', () => {
render(<NotFoundPage />);
expect(screen.getByText('404')).toBeDefined();
expect(screen.getByText('Page not found')).toBeDefined();
});
it('renders custom status code', () => {
render(<NotFoundPage statusCode="403" title="Forbidden" />);
expect(screen.getByText('403')).toBeDefined();
expect(screen.getByText('Forbidden')).toBeDefined();
});
it('renders back button with onClick', () => {
const onBack = vi.fn();
render(<NotFoundPage onBack={onBack} />);
const button = screen.getByText('Go Back');
fireEvent.click(button);
expect(onBack).toHaveBeenCalledOnce();
});
it('renders back link with href', () => {
render(<NotFoundPage backHref="/" backLabel="Go Home" />);
const link = screen.getByText('Go Home');
expect(link.tagName).toBe('A');
expect(link.getAttribute('href')).toBe('/');
});
it('does not render button when neither onBack nor backHref provided', () => {
const { container } = render(<NotFoundPage />);
const buttons = container.querySelectorAll('button');
const links = container.querySelectorAll('a');
expect(buttons.length).toBe(0);
expect(links.length).toBe(0);
});
it('custom backLabel is used', () => {
render(<NotFoundPage onBack={() => {}} backLabel="Return" />);
expect(screen.getByText('Return')).toBeDefined();
});
});

View File

@ -0,0 +1,15 @@
/**
* @bytelyst/dashboard-components
*
* Shared React components for ByteLyst dashboards.
* All components are theme-aware they read CSS custom properties
* (--color-primary, --color-foreground, --color-muted, etc.)
* with sensible fallback defaults.
*/
export { ErrorPage, type ErrorPageProps } from './ErrorPage.js';
export { NotFoundPage, type NotFoundPageProps } from './NotFoundPage.js';
export { LoadingSpinner, type LoadingSpinnerProps } from './LoadingSpinner.js';
export { LoadingSkeleton, type LoadingSkeletonProps } from './LoadingSkeleton.js';
export { EmptyState, type EmptyStateProps } from './EmptyState.js';
export { PageHeader, type PageHeaderProps, type Breadcrumb } from './PageHeader.js';

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"declaration": true,
"declarationMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

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

View File

@ -0,0 +1,39 @@
{
"name": "@bytelyst/dashboard-shell",
"version": "0.1.5",
"description": "Configurable Next.js dashboard layout with sidebar, profile, billing, and settings pages",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
},
"publishConfig": {
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
}
}

View File

@ -0,0 +1,189 @@
import type { ReactNode } from 'react';
import type { BillingPageProps } from './types.js';
const statusColors: Record<string, string> = {
active: 'var(--color-success, #16a34a)',
trialing: 'var(--color-warning, #d97706)',
past_due: 'var(--color-destructive, #dc2626)',
canceled: 'var(--color-muted-foreground, #6b7280)',
};
export function BillingPage({
currentPlan = 'Free',
status = 'active',
trialEndsAt,
onManageBilling,
plans = [],
}: BillingPageProps): ReactNode {
return (
<div data-testid="bl-shell-billing-page" style={{ maxWidth: 800 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Billing
</h1>
{/* Current plan card */}
<div
data-testid="bl-billing-current"
style={{
padding: 24,
borderRadius: 12,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 32,
background: 'var(--color-surface, #fff)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<div
style={{
fontSize: 14,
color: 'var(--color-muted-foreground, #6b7280)',
marginBottom: 4,
}}
>
Current Plan
</div>
<div
style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-foreground, #111827)' }}
>
{currentPlan}
</div>
</div>
<span
data-testid="bl-billing-status"
style={{
padding: '4px 12px',
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
color: statusColors[status] || statusColors.active,
background: `color-mix(in srgb, ${statusColors[status] || statusColors.active} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${statusColors[status] || statusColors.active} 30%, transparent)`,
}}
>
{status.replace('_', ' ')}
</span>
</div>
{trialEndsAt && (
<div
data-testid="bl-billing-trial"
style={{ fontSize: 14, color: 'var(--color-warning, #d97706)', marginBottom: 12 }}
>
Trial ends: {trialEndsAt}
</div>
)}
{onManageBilling && (
<button
data-testid="bl-billing-manage"
onClick={onManageBilling}
style={{
padding: '10px 20px',
borderRadius: 8,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
background: 'var(--color-surface, #fff)',
color: 'var(--color-foreground, #111827)',
}}
>
Manage Billing
</button>
)}
</div>
{/* Plan comparison */}
{plans.length > 0 && (
<div>
<h2
style={{
fontSize: 18,
fontWeight: 600,
marginBottom: 16,
color: 'var(--color-foreground, #111827)',
}}
>
Available Plans
</h2>
<div
data-testid="bl-billing-plans"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
gap: 16,
}}
>
{plans.map(plan => (
<div
key={plan.name}
data-testid={`bl-billing-plan-${plan.name.toLowerCase()}`}
style={{
padding: 24,
borderRadius: 12,
border: plan.current
? '2px solid var(--bl-shell-accent, var(--color-primary, #2563eb))'
: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
background: 'var(--color-surface, #fff)',
}}
>
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{plan.name}</div>
<div
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 16,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
}}
>
{plan.price}
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{plan.features.map(f => (
<li
key={f}
style={{
fontSize: 14,
padding: '4px 0',
color: 'var(--color-muted-foreground, #6b7280)',
}}
>
{f}
</li>
))}
</ul>
{plan.current && (
<div
style={{
marginTop: 16,
fontSize: 13,
fontWeight: 600,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
}}
>
Current Plan
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useState, type ReactNode } from 'react';
import type { DashboardShellProps } from './types.js';
import { Sidebar } from './Sidebar.js';
import { TopBar } from './TopBar.js';
export function DashboardShell({
productName,
logo,
version,
nav,
pathname: externalPathname,
user,
features = {},
onSignOut,
onNavigate,
sidebarFooter,
topBarActions,
children,
}: DashboardShellProps): ReactNode {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Use external pathname or default to '/'
const pathname = externalPathname ?? '/';
return (
<div
data-testid="bl-dashboard-shell"
style={{
display: 'flex',
minHeight: '100vh',
background: 'var(--bl-shell-bg, var(--color-background, #f9fafb))',
}}
>
{/* Sidebar */}
<Sidebar
productName={productName}
logo={logo}
version={version}
nav={nav}
pathname={pathname}
features={features}
onNavigate={onNavigate}
footer={sidebarFooter}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
{/* Main content area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top bar */}
<TopBar
user={user}
features={features}
onSignOut={onSignOut}
onNavigate={onNavigate}
actions={topBarActions}
/>
{/* Page content */}
<main
data-testid="bl-shell-main"
style={{
flex: 1,
padding: '24px 32px',
overflow: 'auto',
}}
>
{children}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
import { useState, type ReactNode } from 'react';
import type { ProfilePageProps } from './types.js';
export function ProfilePage({
user,
onUpdateProfile,
isLoading,
error,
success,
}: ProfilePageProps): ReactNode {
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onUpdateProfile) onUpdateProfile({ name, email });
};
return (
<div data-testid="bl-shell-profile-page" style={{ maxWidth: 600 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Profile
</h1>
{error && (
<div data-testid="bl-profile-error" style={alertStyle('var(--color-destructive, #dc2626)')}>
{error}
</div>
)}
{success && (
<div data-testid="bl-profile-success" style={alertStyle('var(--color-success, #16a34a)')}>
{success}
</div>
)}
{/* Avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 32 }}>
<div
data-testid="bl-profile-avatar"
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
fontWeight: 600,
}}
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
style={{ width: 64, height: 64, borderRadius: '50%' }}
/>
) : (
user.name
.split(' ')
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
)}
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{user.name}</div>
<div style={{ fontSize: 14, color: 'var(--color-muted-foreground, #6b7280)' }}>
{user.email}
</div>
{user.role && (
<div
style={{
fontSize: 12,
color: 'var(--color-muted-foreground, #6b7280)',
marginTop: 2,
}}
>
Role: {user.role}
</div>
)}
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label style={labelStyle} htmlFor="bl-profile-name">
Name
</label>
<input
id="bl-profile-name"
data-testid="bl-profile-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle} htmlFor="bl-profile-email">
Email
</label>
<input
id="bl-profile-email"
data-testid="bl-profile-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={inputStyle}
/>
</div>
{onUpdateProfile && (
<button
data-testid="bl-profile-submit"
type="submit"
disabled={isLoading}
style={{
padding: '10px 20px',
borderRadius: 8,
border: 'none',
fontSize: 14,
fontWeight: 600,
cursor: isLoading ? 'not-allowed' : 'pointer',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
opacity: isLoading ? 0.6 : 1,
alignSelf: 'flex-start',
}}
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
)}
</form>
</div>
);
}
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 14,
fontWeight: 500,
marginBottom: 6,
color: 'var(--color-foreground, #111827)',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 8,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 14,
background: 'var(--color-surface, #fff)',
color: 'var(--color-foreground, #111827)',
boxSizing: 'border-box',
};
function alertStyle(color: string): React.CSSProperties {
return {
padding: '10px 14px',
borderRadius: 8,
marginBottom: 16,
fontSize: 14,
color,
background: `color-mix(in srgb, ${color} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
};
}

View File

@ -0,0 +1,71 @@
import type { ReactNode } from 'react';
import type { SettingsPageProps } from './types.js';
export function SettingsPage({ productName, sections = [] }: SettingsPageProps): ReactNode {
return (
<div data-testid="bl-shell-settings-page" style={{ maxWidth: 700 }}>
<h1
style={{
fontSize: 24,
fontWeight: 700,
marginBottom: 24,
color: 'var(--color-foreground, #111827)',
}}
>
Settings
</h1>
{sections.length === 0 && (
<div
data-testid="bl-settings-empty"
style={{
padding: 32,
textAlign: 'center',
color: 'var(--color-muted-foreground, #6b7280)',
fontSize: 14,
}}
>
No settings configured for {productName}.
</div>
)}
{sections.map((section, i) => (
<div
key={i}
data-testid={`bl-settings-section-${i}`}
style={{
padding: 24,
borderRadius: 12,
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 16,
background: 'var(--color-surface, #fff)',
}}
>
<h2
style={{
fontSize: 16,
fontWeight: 600,
marginBottom: 4,
color: 'var(--color-foreground, #111827)',
}}
>
{section.title}
</h2>
{section.description && (
<p
style={{
fontSize: 14,
color: 'var(--color-muted-foreground, #6b7280)',
marginBottom: 16,
marginTop: 0,
}}
>
{section.description}
</p>
)}
<div>{section.content}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,236 @@
import type { ReactNode } from 'react';
import type { SidebarProps, NavItem, NavSection } from './types.js';
function isNavSections(nav: NavItem[] | NavSection[]): nav is NavSection[] {
return nav.length > 0 && 'items' in nav[0];
}
function NavLink({
item,
active,
collapsed,
onNavigate,
}: {
item: NavItem;
active: boolean;
collapsed: boolean;
onNavigate?: (href: string) => void;
}): ReactNode {
if (item.hidden) return null;
const handleClick = (e: React.MouseEvent) => {
if (onNavigate) {
e.preventDefault();
onNavigate(item.href);
}
};
return (
<a
href={item.href}
onClick={handleClick}
data-testid={`bl-nav-${item.href.replace(/\//g, '-').replace(/^-/, '')}`}
title={collapsed ? item.label : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '10px 0' : '10px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius: 8,
fontSize: 14,
fontWeight: active ? 600 : 400,
color: active
? 'var(--bl-shell-nav-active-text, var(--color-foreground, #111827))'
: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
background: active
? 'var(--bl-shell-nav-active-bg, var(--color-muted, #f3f4f6))'
: 'transparent',
textDecoration: 'none',
transition: 'background 0.15s, color 0.15s',
}}
>
{item.icon && (
<span style={{ fontSize: 16, width: 20, textAlign: 'center', flexShrink: 0 }}>
{item.icon}
</span>
)}
{!collapsed && <span style={{ flex: 1 }}>{item.label}</span>}
{!collapsed && item.badge !== undefined && (
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: '2px 6px',
borderRadius: 10,
background: 'var(--bl-shell-badge-bg, var(--color-primary, #2563eb))',
color: 'var(--bl-shell-badge-text, #fff)',
}}
>
{item.badge}
</span>
)}
</a>
);
}
export function Sidebar({
productName,
logo,
version,
nav,
pathname,
features = {},
onNavigate,
footer,
collapsed = false,
onToggleCollapse,
}: SidebarProps): ReactNode {
const sections: NavSection[] = isNavSections(nav) ? nav : [{ items: nav }];
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
// Add built-in settings nav if enabled and not already present
const hasSettings = features.settings !== false;
const allItems = sections.flatMap(s => s.items);
const settingsExists = allItems.some(i => i.href === '/settings');
return (
<aside
data-testid="bl-shell-sidebar"
style={{
width: collapsed ? 64 : 220,
minHeight: '100vh',
background: 'var(--bl-shell-sidebar-bg, var(--color-surface, #fff))',
borderRight: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
padding: '16px 0',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
padding: collapsed ? '0 8px 16px' : '0 16px 16px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
marginBottom: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<div style={{ overflow: 'hidden', flex: 1 }}>
{logo ? (
<div data-testid="bl-shell-logo">{logo}</div>
) : (
<span
data-testid="bl-shell-product-name"
style={{
fontSize: collapsed ? 14 : 18,
fontWeight: 700,
color: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
whiteSpace: 'nowrap',
}}
>
{collapsed ? productName.charAt(0) : productName}
</span>
)}
</div>
{onToggleCollapse && (
<button
data-testid="bl-shell-collapse-toggle"
onClick={onToggleCollapse}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '▸' : '◂'}
</button>
)}
</div>
{/* Navigation */}
<nav
data-testid="bl-shell-nav"
style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: collapsed ? '0 8px' : '0 8px',
flex: 1,
}}
>
{sections.map((section, si) => (
<div key={si}>
{section.title && !collapsed && (
<div
style={{
padding: '12px 12px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
>
{section.title}
</div>
)}
{section.items.map(item => (
<NavLink
key={item.href}
item={item}
active={isActive(item.href)}
collapsed={collapsed}
onNavigate={onNavigate}
/>
))}
</div>
))}
{/* Built-in settings link */}
{hasSettings && !settingsExists && (
<div style={{ marginTop: 'auto' }}>
<NavLink
item={{ href: '/settings', label: 'Settings', icon: '⚙' }}
active={isActive('/settings')}
collapsed={collapsed}
onNavigate={onNavigate}
/>
</div>
)}
</nav>
{/* Footer */}
<div
data-testid="bl-shell-sidebar-footer"
style={{
marginTop: 'auto',
padding: collapsed ? '12px 8px' : '12px 16px',
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
fontSize: 12,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
textAlign: collapsed ? 'center' : 'left',
}}
>
{footer
? footer
: version
? collapsed
? `v${version}`
: `${productName} v${version}`
: null}
</div>
</aside>
);
}

View File

@ -0,0 +1,244 @@
import { useState, type ReactNode } from 'react';
import type { TopBarProps } from './types.js';
export function TopBar({
user,
features = {},
onSignOut,
onNavigate,
actions,
onToggleSidebar,
}: TopBarProps): ReactNode {
const [menuOpen, setMenuOpen] = useState(false);
const handleNav = (href: string) => {
setMenuOpen(false);
if (onNavigate) onNavigate(href);
};
const initials = user
? user.name
.split(' ')
.map(w => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
: '?';
return (
<header
data-testid="bl-shell-topbar"
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
flexShrink: 0,
}}
>
{/* Left: mobile hamburger */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{onToggleSidebar && (
<button
data-testid="bl-shell-hamburger"
onClick={onToggleSidebar}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 20,
padding: 4,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label="Toggle sidebar"
>
</button>
)}
</div>
{/* Right: actions + user menu */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{actions}
{features.notifications && (
<button
data-testid="bl-shell-notifications"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 18,
padding: 4,
color: 'var(--bl-shell-nav-text, var(--color-muted-foreground, #6b7280))',
}}
aria-label="Notifications"
>
🔔
</button>
)}
{user && (
<div style={{ position: 'relative' }}>
<button
data-testid="bl-shell-user-menu-trigger"
onClick={() => setMenuOpen(!menuOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 8,
}}
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
style={{ width: 32, height: 32, borderRadius: '50%' }}
/>
) : (
<div
data-testid="bl-shell-user-avatar"
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
fontWeight: 600,
}}
>
{initials}
</div>
)}
<span
style={{
fontSize: 14,
color: 'var(--color-foreground, #111827)',
}}
>
{user.name}
</span>
<span style={{ fontSize: 10 }}></span>
</button>
{menuOpen && (
<div
data-testid="bl-shell-user-menu"
style={{
position: 'absolute',
right: 0,
top: '100%',
marginTop: 4,
minWidth: 180,
background: 'var(--bl-shell-topbar-bg, var(--color-surface, #fff))',
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 50,
overflow: 'hidden',
}}
>
<div
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>{user.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-muted-foreground, #6b7280)' }}>
{user.email}
</div>
</div>
{features.profile !== false && (
<a
data-testid="bl-shell-menu-profile"
href="/profile"
onClick={e => {
e.preventDefault();
handleNav('/profile');
}}
style={menuItemStyle}
>
Profile
</a>
)}
{features.billing && (
<a
data-testid="bl-shell-menu-billing"
href="/billing"
onClick={e => {
e.preventDefault();
handleNav('/billing');
}}
style={menuItemStyle}
>
Billing
</a>
)}
{features.settings !== false && (
<a
data-testid="bl-shell-menu-settings"
href="/settings"
onClick={e => {
e.preventDefault();
handleNav('/settings');
}}
style={menuItemStyle}
>
Settings
</a>
)}
{onSignOut && (
<button
data-testid="bl-shell-menu-signout"
onClick={() => {
setMenuOpen(false);
onSignOut();
}}
style={{
...menuItemStyle,
width: '100%',
textAlign: 'left',
borderTop: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
color: 'var(--color-destructive, #dc2626)',
background: 'none',
border: 'none',
borderTopStyle: 'solid',
borderTopWidth: 1,
borderTopColor: 'var(--bl-shell-border, var(--color-border, #e5e7eb))',
}}
>
Sign Out
</button>
)}
</div>
)}
</div>
)}
</div>
</header>
);
}
const menuItemStyle: React.CSSProperties = {
display: 'block',
padding: '10px 16px',
fontSize: 14,
color: 'var(--color-foreground, #111827)',
textDecoration: 'none',
cursor: 'pointer',
};

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