feat: migrate web to Gitea registry, add /api/devops/info, fix role drift
Backend: - Fix role drift: /api/me/profile now returns JWT role authoritatively (was reading drifting role from trading_users). PATCH strips client-supplied role. - Add /api/devops/info endpoint backed by @bytelyst/devops/server. - Dockerfile: bake BYTELYST_COMMIT_SHA / BYTELYST_BUILT_AT / etc. as build args. Web: - Migrate from vendor/ + .pnpmfile.cjs to Gitea npm registry (consistency with backend). - Replace file: refs in web/package.json with semver ranges resolved from Gitea. - Drop vendor/bytelyst/* tree and .pnpmfile.cjs. - Add DevOpsTab in Settings using @bytelyst/devops/ui (tabbed: Build/Runtime/Config/Deps/Raw). - Vite alias: restrict @bytelyst/* catch-all to single-segment names so subpath imports (@bytelyst/devops/ui) resolve via package exports map. - Bake BYTELYST_* metadata into the bundle as VITE_BYTELYST_* env. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
b1667471c9
commit
74400fda70
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
mobile
|
||||||
|
ios
|
||||||
|
android
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
web/.next
|
||||||
@ -1 +1 @@
|
|||||||
@bytelyst:registry=http://gitea.bytelyst.com:3300/api/packages/bytelyst/npm/
|
@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/
|
||||||
|
|||||||
@ -1,30 +1,45 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app/backend
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
|
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
|
||||||
|
|
||||||
# Use Gitea npm registry for @bytelyst/* packages
|
# Use Gitea npm registry for @bytelyst/* packages
|
||||||
COPY .npmrc.docker ./.npmrc
|
|
||||||
COPY backend/package.json ./package.json
|
COPY backend/package.json ./package.json
|
||||||
|
|
||||||
RUN --mount=type=secret,id=gitea_npm_token \
|
RUN --mount=type=secret,id=gitea_npm_token \
|
||||||
TOKEN=$(cat /run/secrets/gitea_npm_token) && \
|
TOKEN=$(cat /run/secrets/gitea_npm_token) && \
|
||||||
echo "//gitea.bytelyst.com:3300/:_authToken=$TOKEN" >> .npmrc && \
|
printf '@bytelyst:registry=http://localhost:3300/api/packages/bytelyst/npm/\n//localhost:3300/api/packages/bytelyst/npm/:_authToken=%s\nlink-workspace-packages=false\nshared-workspace-lockfile=false\n' "$TOKEN" > .npmrc && \
|
||||||
pnpm install --ignore-scripts --lockfile=false
|
pnpm install --ignore-scripts --lockfile=false
|
||||||
|
|
||||||
COPY backend/tsconfig.json ./tsconfig.json
|
COPY backend/tsconfig.json ./backend/tsconfig.json
|
||||||
COPY backend/src/ ./src/
|
COPY backend/src/ ./backend/src/
|
||||||
COPY shared/ ../shared/
|
COPY shared/ ./shared/
|
||||||
RUN pnpm run build
|
RUN cd /app/backend && ../node_modules/.bin/tsc -p tsconfig.json
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY --from=builder /app/backend/node_modules ./node_modules
|
# Build metadata baked at image build time (consumed by @bytelyst/devops)
|
||||||
COPY --from=builder /app/backend/package.json ./package.json
|
ARG BYTELYST_COMMIT_SHA=unknown
|
||||||
|
ARG BYTELYST_COMMIT_SHA_FULL=unknown
|
||||||
|
ARG BYTELYST_BRANCH=unknown
|
||||||
|
ARG BYTELYST_BUILT_AT=unknown
|
||||||
|
ARG BYTELYST_COMMIT_AUTHOR=unknown
|
||||||
|
ARG BYTELYST_COMMIT_MESSAGE=unknown
|
||||||
|
ARG BYTELYST_DOCKER_IMAGE=invttrdg-backend:latest
|
||||||
|
ENV BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA} \
|
||||||
|
BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL} \
|
||||||
|
BYTELYST_BRANCH=${BYTELYST_BRANCH} \
|
||||||
|
BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT} \
|
||||||
|
BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR} \
|
||||||
|
BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE} \
|
||||||
|
BYTELYST_DOCKER_IMAGE=${BYTELYST_DOCKER_IMAGE}
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
COPY --from=builder /app/backend/dist ./dist
|
COPY --from=builder /app/backend/dist ./dist
|
||||||
COPY shared/ ../shared/
|
COPY --from=builder /app/shared/ ../shared/
|
||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@ -75,7 +75,9 @@
|
|||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"winston": "^3.19.0"
|
"winston": "^3.19.0",
|
||||||
|
"@bytelyst/telemetry-client": "*",
|
||||||
|
"@bytelyst/devops": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { createServer } from 'http';
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { collectDevopsInfo } from '@bytelyst/devops/server';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -2364,6 +2365,20 @@ export class ApiServer {
|
|||||||
res.json(flags);
|
res.json(flags);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── DevOps info: build, runtime, config (auth required) ──────────────
|
||||||
|
this.app.get('/api/devops/info', this.requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const info = await collectDevopsInfo({
|
||||||
|
productId: config.PRODUCT_ID || 'invttrdg',
|
||||||
|
serviceName: 'trading-backend',
|
||||||
|
serviceVersion: process.env.npm_package_version || '0.1.0',
|
||||||
|
});
|
||||||
|
res.json(info);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.app.get('/api/me/profile', this.requireAuth, async (req, res) => {
|
this.app.get('/api/me/profile', this.requireAuth, async (req, res) => {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
const authUserId = authReq.authUserId;
|
const authUserId = authReq.authUserId;
|
||||||
@ -2381,6 +2396,11 @@ export class ApiServer {
|
|||||||
trade_enable: true,
|
trade_enable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// JWT role is authoritative — prevent drift between platform users and trading_users.
|
||||||
|
if (authReq.authRole) {
|
||||||
|
profile.role = authReq.authRole;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ profile });
|
res.json({ profile });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2394,13 +2414,19 @@ export class ApiServer {
|
|||||||
|
|
||||||
const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean);
|
const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean);
|
||||||
try {
|
try {
|
||||||
const profile = await saveCurrentUserProfile(authUserId, req.body || {}, {
|
// Strip role from client-supplied patch — role comes from JWT only.
|
||||||
|
const { role: _ignoredRole, ...patchBody } = (req.body || {}) as Record<string, unknown>;
|
||||||
|
const profile = await saveCurrentUserProfile(authUserId, patchBody, {
|
||||||
email: authReq.authEmail,
|
email: authReq.authEmail,
|
||||||
role: authReq.authRole,
|
role: authReq.authRole,
|
||||||
first_name: displayNameParts[0] || '',
|
first_name: displayNameParts[0] || '',
|
||||||
last_name: displayNameParts.slice(1).join(' '),
|
last_name: displayNameParts.slice(1).join(' '),
|
||||||
trade_enable: true,
|
trade_enable: true,
|
||||||
});
|
});
|
||||||
|
// JWT role is authoritative.
|
||||||
|
if (authReq.authRole) {
|
||||||
|
profile.role = authReq.authRole;
|
||||||
|
}
|
||||||
res.json({ profile });
|
res.json({ profile });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(400).json({ error: `Failed to update profile: ${error.message}` });
|
res.status(400).json({ error: `Failed to update profile: ${error.message}` });
|
||||||
|
|||||||
20
vendor/bytelyst/accessibility/package.json
vendored
20
vendor/bytelyst/accessibility/package.json
vendored
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/accessibility",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc"
|
|
||||||
},
|
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
204
vendor/bytelyst/accessibility/src/index.ts
vendored
204
vendor/bytelyst/accessibility/src/index.ts
vendored
@ -1,204 +0,0 @@
|
|||||||
export type AlertA11yProps = {
|
|
||||||
role: 'alert';
|
|
||||||
'aria-live': 'assertive' | 'polite';
|
|
||||||
'aria-label': string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function alertLabel(level: string, description: string): AlertA11yProps {
|
|
||||||
return {
|
|
||||||
role: 'alert',
|
|
||||||
'aria-live': level === 'danger' ? 'assertive' : 'polite',
|
|
||||||
'aria-label': description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR = [
|
|
||||||
'a[href]',
|
|
||||||
'button:not([disabled])',
|
|
||||||
'input:not([disabled])',
|
|
||||||
'select:not([disabled])',
|
|
||||||
'textarea:not([disabled])',
|
|
||||||
'[tabindex]:not([tabindex="-1"])',
|
|
||||||
].join(',');
|
|
||||||
|
|
||||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
|
||||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
||||||
element =>
|
|
||||||
!element.hasAttribute('disabled') &&
|
|
||||||
element.getAttribute('aria-hidden') !== 'true' &&
|
|
||||||
element.offsetParent !== null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trapFocusKeydown(event: KeyboardEvent, container: HTMLElement): void {
|
|
||||||
if (event.key !== 'Tab') return;
|
|
||||||
|
|
||||||
const focusable = getFocusableElements(container);
|
|
||||||
if (focusable.length === 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusFirstElement(container: HTMLElement, selector = FOCUSABLE_SELECTOR): void {
|
|
||||||
const preferred = container.querySelector<HTMLElement>(selector);
|
|
||||||
const fallback = getFocusableElements(container)[0];
|
|
||||||
(preferred ?? fallback)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScreenReaderPoliteness = 'assertive' | 'polite';
|
|
||||||
|
|
||||||
export function announceToScreenReader(
|
|
||||||
message: string,
|
|
||||||
politeness: ScreenReaderPoliteness = 'polite'
|
|
||||||
): void {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const id = `bytelyst-sr-announcer-${politeness}`;
|
|
||||||
let announcer = document.getElementById(id);
|
|
||||||
if (!announcer) {
|
|
||||||
announcer = document.createElement('div');
|
|
||||||
announcer.id = id;
|
|
||||||
announcer.setAttribute('aria-live', politeness);
|
|
||||||
announcer.setAttribute('aria-atomic', 'true');
|
|
||||||
announcer.style.position = 'absolute';
|
|
||||||
announcer.style.width = '1px';
|
|
||||||
announcer.style.height = '1px';
|
|
||||||
announcer.style.margin = '-1px';
|
|
||||||
announcer.style.padding = '0';
|
|
||||||
announcer.style.overflow = 'hidden';
|
|
||||||
announcer.style.clip = 'rect(0 0 0 0)';
|
|
||||||
announcer.style.whiteSpace = 'nowrap';
|
|
||||||
announcer.style.border = '0';
|
|
||||||
document.body.appendChild(announcer);
|
|
||||||
}
|
|
||||||
|
|
||||||
announcer.textContent = '';
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (announcer) {
|
|
||||||
announcer.textContent = message;
|
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProgressbarA11yProps = {
|
|
||||||
role: 'progressbar';
|
|
||||||
'aria-label': string;
|
|
||||||
'aria-valuenow': number;
|
|
||||||
'aria-valuemin': number;
|
|
||||||
'aria-valuemax': number;
|
|
||||||
'aria-valuetext': string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function progressLabel(
|
|
||||||
label: string,
|
|
||||||
valuePct: number,
|
|
||||||
description: string
|
|
||||||
): ProgressbarA11yProps {
|
|
||||||
return {
|
|
||||||
role: 'progressbar',
|
|
||||||
'aria-label': label,
|
|
||||||
'aria-valuenow': valuePct,
|
|
||||||
'aria-valuemin': 0,
|
|
||||||
'aria-valuemax': 100,
|
|
||||||
'aria-valuetext': description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AriaLabelOnly = { 'aria-label': string };
|
|
||||||
|
|
||||||
export function streakLabel(days: number): AriaLabelOnly {
|
|
||||||
return { 'aria-label': `${days} day streak` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ButtonA11yProps = {
|
|
||||||
'aria-label': string;
|
|
||||||
'aria-roledescription'?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buttonLabel(text: string, hint?: string): ButtonA11yProps {
|
|
||||||
return {
|
|
||||||
'aria-label': text,
|
|
||||||
...(hint ? { 'aria-roledescription': hint } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function achievementLabel(name: string, description: string): AriaLabelOnly {
|
|
||||||
return { 'aria-label': `Achievement: ${name} — ${description}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TimerA11yProps = {
|
|
||||||
'aria-label': string;
|
|
||||||
'aria-live': 'polite';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function timerLabel(
|
|
||||||
hours: number,
|
|
||||||
minutes: number,
|
|
||||||
seconds: number,
|
|
||||||
status: string
|
|
||||||
): TimerA11yProps {
|
|
||||||
return {
|
|
||||||
'aria-label': `Timer: ${hours}h ${minutes}m ${seconds}s, ${status}`,
|
|
||||||
'aria-live': 'polite',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SliderA11yProps = {
|
|
||||||
role: 'slider';
|
|
||||||
'aria-label': string;
|
|
||||||
'aria-valuenow': number;
|
|
||||||
'aria-valuemin': number;
|
|
||||||
'aria-valuemax': number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function sliderLabel(
|
|
||||||
label: string,
|
|
||||||
value: number,
|
|
||||||
min: number,
|
|
||||||
max: number
|
|
||||||
): SliderA11yProps {
|
|
||||||
return {
|
|
||||||
role: 'slider',
|
|
||||||
'aria-label': label,
|
|
||||||
'aria-valuenow': value,
|
|
||||||
'aria-valuemin': min,
|
|
||||||
'aria-valuemax': max,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function plural(n: number, singular: string, pluralForm: string): string {
|
|
||||||
const word = n === 1 ? singular : pluralForm;
|
|
||||||
return `${n} ${word}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spoken-friendly duration from a fractional hour value, e.g. 12 → "12 hours", 1.5 → "1 hour 30 minutes".
|
|
||||||
*/
|
|
||||||
export function formatDurationForA11y(hours: number): string {
|
|
||||||
const totalMinutes = Math.round(hours * 60);
|
|
||||||
const h = Math.floor(totalMinutes / 60);
|
|
||||||
const m = totalMinutes % 60;
|
|
||||||
|
|
||||||
if (h === 0 && m === 0) {
|
|
||||||
return '0 minutes';
|
|
||||||
}
|
|
||||||
if (m === 0) {
|
|
||||||
return plural(h, 'hour', 'hours');
|
|
||||||
}
|
|
||||||
if (h === 0) {
|
|
||||||
return plural(m, 'minute', 'minutes');
|
|
||||||
}
|
|
||||||
return `${plural(h, 'hour', 'hours')} ${plural(m, 'minute', 'minutes')}`;
|
|
||||||
}
|
|
||||||
18
vendor/bytelyst/accessibility/tsconfig.json
vendored
18
vendor/bytelyst/accessibility/tsconfig.json
vendored
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"lib": ["ES2022", "DOM"],
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
23
vendor/bytelyst/api-client/package.json
vendored
23
vendor/bytelyst/api-client/package.json
vendored
@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/api-client",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
||||||
import { createApiClient } from '../index.js';
|
|
||||||
|
|
||||||
// Mock globalThis.fetch
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
globalThis.fetch = mockFetch;
|
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status = 200) {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
statusText: status === 200 ? 'OK' : 'Error',
|
|
||||||
json: () => Promise.resolve(data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createApiClient', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an object with fetch and safeFetch', () => {
|
|
||||||
const api = createApiClient({ baseUrl: 'http://localhost:4003' });
|
|
||||||
expect(typeof api.fetch).toBe('function');
|
|
||||||
expect(typeof api.safeFetch).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fetch', () => {
|
|
||||||
it('calls correct URL with base + path', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ users: [] }));
|
|
||||||
const api = createApiClient({ baseUrl: 'http://localhost:4003/api' });
|
|
||||||
|
|
||||||
await api.fetch('/users');
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:4003/api/users',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns parsed JSON on success', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ id: '1', name: 'Test' }));
|
|
||||||
const api = createApiClient({ baseUrl: '/api' });
|
|
||||||
|
|
||||||
const result = await api.fetch<{ id: string; name: string }>('/users/1');
|
|
||||||
expect(result).toEqual({ id: '1', name: 'Test' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on HTTP error', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ error: 'Not found' }, 404));
|
|
||||||
const api = createApiClient({ baseUrl: '/api' });
|
|
||||||
|
|
||||||
await expect(api.fetch('/users/999')).rejects.toThrow('Not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('injects auth token from getToken', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
|
|
||||||
const api = createApiClient({
|
|
||||||
baseUrl: '/api',
|
|
||||||
getToken: () => 'my-jwt-token',
|
|
||||||
});
|
|
||||||
|
|
||||||
await api.fetch('/protected');
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
'/api/protected',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer my-jwt-token',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips auth header when getToken returns null', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
|
|
||||||
const api = createApiClient({
|
|
||||||
baseUrl: '/api',
|
|
||||||
getToken: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await api.fetch('/public');
|
|
||||||
|
|
||||||
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
|
|
||||||
expect(headers.Authorization).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges defaultHeaders', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ ok: true }));
|
|
||||||
const api = createApiClient({
|
|
||||||
baseUrl: '/api',
|
|
||||||
defaultHeaders: { 'X-Custom': 'value' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await api.fetch('/test');
|
|
||||||
|
|
||||||
const headers = mockFetch.mock.calls[0][1].headers as Record<string, string>;
|
|
||||||
expect(headers['X-Custom']).toBe('value');
|
|
||||||
expect(headers['Content-Type']).toBe('application/json');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('safeFetch', () => {
|
|
||||||
it('returns { data, error: null } on success', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ id: '1' }));
|
|
||||||
const api = createApiClient({ baseUrl: '/api' });
|
|
||||||
|
|
||||||
const result = await api.safeFetch<{ id: string }>('/items/1');
|
|
||||||
expect(result.data).toEqual({ id: '1' });
|
|
||||||
expect(result.error).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { data: null, error } on HTTP error', async () => {
|
|
||||||
mockFetch.mockResolvedValue(jsonResponse({ error: 'Forbidden' }, 403));
|
|
||||||
const api = createApiClient({ baseUrl: '/api' });
|
|
||||||
|
|
||||||
const result = await api.safeFetch('/secret');
|
|
||||||
expect(result.data).toBeNull();
|
|
||||||
expect(result.error).toBe('Forbidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { data: null, error } on network error', async () => {
|
|
||||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
||||||
const api = createApiClient({ baseUrl: '/api' });
|
|
||||||
|
|
||||||
const result = await api.safeFetch('/unreachable');
|
|
||||||
expect(result.data).toBeNull();
|
|
||||||
expect(result.error).toBe('API unavailable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
151
vendor/bytelyst/api-client/src/client.ts
vendored
151
vendor/bytelyst/api-client/src/client.ts
vendored
@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* Configurable API client factory.
|
|
||||||
* Creates a fetch wrapper with base URL, auth token injection, error handling,
|
|
||||||
* timeout, and retry with exponential backoff for idempotent requests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ApiClient, ApiClientConfig, ApiResult } from './types.js';
|
|
||||||
|
|
||||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an API client with a base URL and optional auth token.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const api = createApiClient({
|
|
||||||
* baseUrl: "/api",
|
|
||||||
* getToken: () => localStorage.getItem("access_token"),
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Throws on error
|
|
||||||
* const users = await api.fetch<User[]>("/users");
|
|
||||||
*
|
|
||||||
* // Never throws
|
|
||||||
* const { data, error } = await api.safeFetch<User[]>("/users");
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createApiClient(config: ApiClientConfig): ApiClient {
|
|
||||||
const {
|
|
||||||
baseUrl,
|
|
||||||
getToken,
|
|
||||||
defaultHeaders,
|
|
||||||
timeoutMs = 10_000,
|
|
||||||
retries = 2,
|
|
||||||
retryDelayMs = 500,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
function buildHeaders(options?: RequestInit): HeadersInit {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-request-id':
|
|
||||||
typeof globalThis.crypto?.randomUUID === 'function'
|
|
||||||
? globalThis.crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
...defaultHeaders,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (getToken) {
|
|
||||||
const token = getToken();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.headers) {
|
|
||||||
const extra: Record<string, string> = {};
|
|
||||||
if (options.headers instanceof Headers) {
|
|
||||||
options.headers.forEach((value, key) => {
|
|
||||||
extra[key] = value;
|
|
||||||
});
|
|
||||||
} else if (Array.isArray(options.headers)) {
|
|
||||||
Object.assign(extra, Object.fromEntries(options.headers));
|
|
||||||
} else {
|
|
||||||
Object.assign(extra, options.headers);
|
|
||||||
}
|
|
||||||
Object.assign(headers, extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInit(options?: RequestInit): RequestInit {
|
|
||||||
const init: RequestInit = {
|
|
||||||
...options,
|
|
||||||
headers: buildHeaders(options),
|
|
||||||
};
|
|
||||||
|
|
||||||
// AbortController timeout (skip if caller already supplies a signal or timeoutMs is 0)
|
|
||||||
if (timeoutMs > 0 && !options?.signal) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
init.signal = controller.signal;
|
|
||||||
setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return init;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRetryable(method: string | undefined): boolean {
|
|
||||||
return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
|
||||||
const maxAttempts = isRetryable(init.method) ? retries + 1 : 1;
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const res = await globalThis.fetch(url, init);
|
|
||||||
// Only retry on 502/503/504 for idempotent methods
|
|
||||||
if (res.status >= 502 && res.status <= 504 && attempt < maxAttempts - 1) {
|
|
||||||
await sleep(retryDelayMs * 2 ** attempt);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (attempt < maxAttempts - 1) {
|
|
||||||
await sleep(retryDelayMs * 2 ** attempt);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|
||||||
const init = buildInit(options);
|
|
||||||
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
throw new Error(body.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
},
|
|
||||||
|
|
||||||
async safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>> {
|
|
||||||
try {
|
|
||||||
const init = buildInit(options);
|
|
||||||
const res = await fetchWithRetry(`${baseUrl}${path}`, init);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
||||||
return { data: null, error: body.error || `HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as T;
|
|
||||||
return { data, error: null };
|
|
||||||
} catch {
|
|
||||||
return { data: null, error: 'API unavailable' };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
2
vendor/bytelyst/api-client/src/index.ts
vendored
2
vendor/bytelyst/api-client/src/index.ts
vendored
@ -1,2 +0,0 @@
|
|||||||
export { createApiClient } from './client.js';
|
|
||||||
export type { ApiClient, ApiClientConfig, ApiResult } from './types.js';
|
|
||||||
28
vendor/bytelyst/api-client/src/types.ts
vendored
28
vendor/bytelyst/api-client/src/types.ts
vendored
@ -1,28 +0,0 @@
|
|||||||
export interface ApiClientConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
getToken?: () => string | null;
|
|
||||||
defaultHeaders?: Record<string, string>;
|
|
||||||
/** Request timeout in milliseconds. Default: 10000 (10s). Set 0 to disable. */
|
|
||||||
timeoutMs?: number;
|
|
||||||
/** Max retries for idempotent requests (GET/HEAD/OPTIONS). Default: 2. */
|
|
||||||
retries?: number;
|
|
||||||
/** Base delay in ms for exponential backoff between retries. Default: 500. */
|
|
||||||
retryDelayMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiResult<T> {
|
|
||||||
data: T | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiClient {
|
|
||||||
/**
|
|
||||||
* Fetch that throws on error — use when caller handles errors.
|
|
||||||
*/
|
|
||||||
fetch<T>(path: string, options?: RequestInit): Promise<T>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safe fetch that never throws — returns { data, error } tuple.
|
|
||||||
*/
|
|
||||||
safeFetch<T>(path: string, options?: RequestInit): Promise<ApiResult<T>>;
|
|
||||||
}
|
|
||||||
10
vendor/bytelyst/api-client/tsconfig.json
vendored
10
vendor/bytelyst/api-client/tsconfig.json
vendored
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"lib": ["ES2022", "DOM"]
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
24
vendor/bytelyst/auth-client/package.json
vendored
24
vendor/bytelyst/auth-client/package.json
vendored
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/auth-client",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Browser/React Native-safe auth API client for platform-service",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,346 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createAuthClient } from '../client.js';
|
|
||||||
import type { TokenStorage } from '../types.js';
|
|
||||||
|
|
||||||
function createMockStorage(): TokenStorage & { store: Map<string, string> } {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
getItem: (key: string) => store.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => store.set(key, value),
|
|
||||||
removeItem: (key: string) => store.delete(key),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockFetchResponse(data: unknown, status = 200) {
|
|
||||||
return vi.fn().mockResolvedValue({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: () => Promise.resolve(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('@bytelyst/auth-client', () => {
|
|
||||||
let storage: ReturnType<typeof createMockStorage>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storage = createMockStorage();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createAuthClient', () => {
|
|
||||||
it('creates a client with all expected methods', () => {
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.login).toBeTypeOf('function');
|
|
||||||
expect(client.register).toBeTypeOf('function');
|
|
||||||
expect(client.getMe).toBeTypeOf('function');
|
|
||||||
expect(client.refreshAccessToken).toBeTypeOf('function');
|
|
||||||
expect(client.forgotPassword).toBeTypeOf('function');
|
|
||||||
expect(client.resetPassword).toBeTypeOf('function');
|
|
||||||
expect(client.changePassword).toBeTypeOf('function');
|
|
||||||
expect(client.deleteAccount).toBeTypeOf('function');
|
|
||||||
expect(client.verifyEmail).toBeTypeOf('function');
|
|
||||||
expect(client.resendVerification).toBeTypeOf('function');
|
|
||||||
expect(client.getAccessToken).toBeTypeOf('function');
|
|
||||||
expect(client.getRefreshToken).toBeTypeOf('function');
|
|
||||||
expect(client.setTokens).toBeTypeOf('function');
|
|
||||||
expect(client.clearTokens).toBeTypeOf('function');
|
|
||||||
expect(client.isAuthenticated).toBeTypeOf('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('token management', () => {
|
|
||||||
it('stores and retrieves tokens', () => {
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
expect(client.getAccessToken()).toBeNull();
|
|
||||||
|
|
||||||
client.setTokens('access-123', 'refresh-456');
|
|
||||||
|
|
||||||
expect(client.isAuthenticated()).toBe(true);
|
|
||||||
expect(client.getAccessToken()).toBe('access-123');
|
|
||||||
expect(client.getRefreshToken()).toBe('refresh-456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears tokens', () => {
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.setTokens('access-123', 'refresh-456');
|
|
||||||
client.clearTokens();
|
|
||||||
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
expect(client.getAccessToken()).toBeNull();
|
|
||||||
expect(client.getRefreshToken()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses productId as storage key prefix by default', () => {
|
|
||||||
createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'chronomind',
|
|
||||||
storage,
|
|
||||||
}).setTokens('a', 'b');
|
|
||||||
|
|
||||||
expect(storage.store.get('chronomind-auth-token')).toBe('a');
|
|
||||||
expect(storage.store.get('chronomind-refresh-token')).toBe('b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects custom storagePrefix', () => {
|
|
||||||
createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'chronomind',
|
|
||||||
storagePrefix: 'cm',
|
|
||||||
storage,
|
|
||||||
}).setTokens('a', 'b');
|
|
||||||
|
|
||||||
expect(storage.store.get('cm-auth-token')).toBe('a');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('login', () => {
|
|
||||||
it('sends correct request and stores tokens', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'at-123',
|
|
||||||
refreshToken: 'rt-456',
|
|
||||||
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.login('a@b.com', 'pass123');
|
|
||||||
|
|
||||||
expect(result.user.email).toBe('a@b.com');
|
|
||||||
expect(client.getAccessToken()).toBe('at-123');
|
|
||||||
expect(client.getRefreshToken()).toBe('rt-456');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/login');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
const body = JSON.parse(opts.body);
|
|
||||||
expect(body.email).toBe('a@b.com');
|
|
||||||
expect(body.productId).toBe('testapp');
|
|
||||||
|
|
||||||
expect(opts.headers['x-product-id']).toBe('testapp');
|
|
||||||
expect(opts.headers['x-request-id']).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on login failure', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Invalid credentials' }, 401);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(client.login('a@b.com', 'wrong')).rejects.toThrow('Invalid credentials');
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('register', () => {
|
|
||||||
it('sends correct request and stores tokens', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'at-new',
|
|
||||||
refreshToken: 'rt-new',
|
|
||||||
user: { id: 'u2', email: 'new@b.com', displayName: 'New', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'nomgap',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.register('new@b.com', 'pass1234', 'New User');
|
|
||||||
|
|
||||||
expect(result.user.displayName).toBe('New');
|
|
||||||
expect(client.getAccessToken()).toBe('at-new');
|
|
||||||
|
|
||||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
||||||
expect(body.displayName).toBe('New User');
|
|
||||||
expect(body.productId).toBe('nomgap');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMe', () => {
|
|
||||||
it('sends authorization header', async () => {
|
|
||||||
const mockUser = { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' };
|
|
||||||
globalThis.fetch = mockFetchResponse(mockUser);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('my-token', 'my-refresh');
|
|
||||||
|
|
||||||
const user = await client.getMe();
|
|
||||||
expect(user.email).toBe('a@b.com');
|
|
||||||
|
|
||||||
const opts = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
|
||||||
expect(opts.headers['Authorization']).toBe('Bearer my-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('refreshAccessToken', () => {
|
|
||||||
it('refreshes and stores new tokens', async () => {
|
|
||||||
const mockRefresh = { accessToken: 'at-refreshed', refreshToken: 'rt-refreshed' };
|
|
||||||
globalThis.fetch = mockFetchResponse(mockRefresh);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('old-at', 'old-rt');
|
|
||||||
|
|
||||||
const ok = await client.refreshAccessToken();
|
|
||||||
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(client.getAccessToken()).toBe('at-refreshed');
|
|
||||||
expect(client.getRefreshToken()).toBe('rt-refreshed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears tokens on refresh failure', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ error: 'expired' }, 401);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('old-at', 'old-rt');
|
|
||||||
|
|
||||||
const ok = await client.refreshAccessToken();
|
|
||||||
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false if no refresh token', async () => {
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ok = await client.refreshAccessToken();
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('forgotPassword', () => {
|
|
||||||
it('sends email and productId', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Reset email sent' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'mindlyst',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.forgotPassword('user@test.com');
|
|
||||||
expect(result.message).toBe('Reset email sent');
|
|
||||||
|
|
||||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
||||||
expect(body.email).toBe('user@test.com');
|
|
||||||
expect(body.productId).toBe('mindlyst');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('changePassword', () => {
|
|
||||||
it('sends authenticated request', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Password changed' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.changePassword('old', 'new12345');
|
|
||||||
expect(result.message).toBe('Password changed');
|
|
||||||
|
|
||||||
const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
|
||||||
const body = JSON.parse(opts.body);
|
|
||||||
expect(body.currentPassword).toBe('old');
|
|
||||||
expect(body.newPassword).toBe('new12345');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deleteAccount', () => {
|
|
||||||
it('clears tokens after deletion', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Deleted' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.deleteAccount('mypassword');
|
|
||||||
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain('/auth/account');
|
|
||||||
expect(opts.method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyEmail', () => {
|
|
||||||
it('sends verification token', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Verified' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.verifyEmail('verify-token-abc');
|
|
||||||
expect(result.message).toBe('Verified');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resendVerification', () => {
|
|
||||||
it('sends email and productId', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Sent' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'chronomind',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.resendVerification('user@test.com');
|
|
||||||
|
|
||||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
||||||
expect(body.email).toBe('user@test.com');
|
|
||||||
expect(body.productId).toBe('chronomind');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,571 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createAuthClient } from '../client.js';
|
|
||||||
import type { TokenStorage } from '../types.js';
|
|
||||||
|
|
||||||
function createMockStorage(): TokenStorage & { store: Map<string, string> } {
|
|
||||||
const store = new Map<string, string>();
|
|
||||||
return {
|
|
||||||
store,
|
|
||||||
getItem: (key: string) => store.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => store.set(key, value),
|
|
||||||
removeItem: (key: string) => store.delete(key),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockFetchResponse(data: unknown, status = 200) {
|
|
||||||
return vi.fn().mockResolvedValue({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: () => Promise.resolve(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('@bytelyst/auth-client — SmartAuth', () => {
|
|
||||||
let storage: ReturnType<typeof createMockStorage>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storage = createMockStorage();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 1C: Google Sign-In ──────────────────────
|
|
||||||
|
|
||||||
describe('loginWithGoogle', () => {
|
|
||||||
it('calls POST /auth/oauth/google and stores tokens', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'google-at',
|
|
||||||
refreshToken: 'google-rt',
|
|
||||||
user: { id: 'u1', email: 'g@gmail.com', displayName: 'G', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.loginWithGoogle('google-id-token-123');
|
|
||||||
|
|
||||||
expect('user' in result).toBe(true);
|
|
||||||
if ('user' in result) {
|
|
||||||
expect(result.user.email).toBe('g@gmail.com');
|
|
||||||
}
|
|
||||||
expect(client.getAccessToken()).toBe('google-at');
|
|
||||||
expect(client.getRefreshToken()).toBe('google-rt');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/oauth/google');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
const body = JSON.parse(opts.body);
|
|
||||||
expect(body.idToken).toBe('google-id-token-123');
|
|
||||||
expect(body.productId).toBe('testapp');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns MFA challenge when mfaRequired is true', async () => {
|
|
||||||
const mfaData = {
|
|
||||||
mfaRequired: true,
|
|
||||||
mfaChallenge: 'challenge-token-abc',
|
|
||||||
methods: ['totp'],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mfaData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.loginWithGoogle('google-id-token-456');
|
|
||||||
|
|
||||||
expect('mfaRequired' in result).toBe(true);
|
|
||||||
if ('mfaRequired' in result) {
|
|
||||||
expect(result.mfaRequired).toBe(true);
|
|
||||||
expect(result.mfaChallenge).toBe('challenge-token-abc');
|
|
||||||
expect(result.methods).toEqual(['totp']);
|
|
||||||
}
|
|
||||||
// Tokens should NOT be stored when MFA is required
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loginWithMicrosoft', () => {
|
|
||||||
it('calls POST /auth/oauth/microsoft', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'ms-at',
|
|
||||||
refreshToken: 'ms-rt',
|
|
||||||
user: { id: 'u2', email: 'ms@outlook.com', displayName: 'MS', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.loginWithMicrosoft('ms-id-token');
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/oauth/microsoft');
|
|
||||||
expect(client.getAccessToken()).toBe('ms-at');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loginWithApple', () => {
|
|
||||||
it('calls POST /auth/oauth/apple', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'apple-at',
|
|
||||||
refreshToken: 'apple-rt',
|
|
||||||
user: { id: 'u3', email: 'a@icloud.com', displayName: 'A', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.loginWithApple('apple-id-token');
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/oauth/apple');
|
|
||||||
expect(client.getAccessToken()).toBe('apple-at');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 1C: Provider management ─────────────────
|
|
||||||
|
|
||||||
describe('getProviders', () => {
|
|
||||||
it('calls GET /auth/providers', async () => {
|
|
||||||
const providers = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provider: 'google',
|
|
||||||
email: 'g@gmail.com',
|
|
||||||
linkedAt: '2025-01-01T00:00:00Z',
|
|
||||||
lastUsedAt: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(providers);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.getProviders();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].provider).toBe('google');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/providers');
|
|
||||||
expect(opts.method).toBe('GET');
|
|
||||||
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('linkProvider', () => {
|
|
||||||
it('calls POST /auth/providers/link with provider and idToken', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.linkProvider('google', 'link-token');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/providers/link');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
const body = JSON.parse(opts.body);
|
|
||||||
expect(body.provider).toBe('google');
|
|
||||||
expect(body.idToken).toBe('link-token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unlinkProvider', () => {
|
|
||||||
it('calls DELETE /auth/providers/:provider', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.unlinkProvider('google');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/providers/google');
|
|
||||||
expect(opts.method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 2D: MFA ─────────────────────────────────
|
|
||||||
|
|
||||||
describe('verifyMfa', () => {
|
|
||||||
it('sends challenge token and TOTP code, stores tokens', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'mfa-at',
|
|
||||||
refreshToken: 'mfa-rt',
|
|
||||||
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.verifyMfa('challenge-xyz', '123456', 'totp');
|
|
||||||
|
|
||||||
expect(result.user.email).toBe('a@b.com');
|
|
||||||
expect(client.getAccessToken()).toBe('mfa-at');
|
|
||||||
|
|
||||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
||||||
expect(body.challengeToken).toBe('challenge-xyz');
|
|
||||||
expect(body.code).toBe('123456');
|
|
||||||
expect(body.method).toBe('totp');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setupTotp', () => {
|
|
||||||
it('calls POST /auth/mfa/setup', async () => {
|
|
||||||
const setup = {
|
|
||||||
secret: 'ABCDEFGH',
|
|
||||||
otpauthUri: 'otpauth://totp/Test?secret=ABC',
|
|
||||||
recoveryCodes: ['code1', 'code2'],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(setup);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.setupTotp();
|
|
||||||
|
|
||||||
expect(result.otpauthUri).toContain('otpauth://');
|
|
||||||
expect(result.secret).toBe('ABCDEFGH');
|
|
||||||
expect(result.recoveryCodes).toHaveLength(2);
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/mfa/setup');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMfaStatus', () => {
|
|
||||||
it('returns MFA status', async () => {
|
|
||||||
const status = { mfaEnabled: true, methods: ['totp'], recoveryCodesRemaining: 6 };
|
|
||||||
globalThis.fetch = mockFetchResponse(status);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.getMfaStatus();
|
|
||||||
expect(result.mfaEnabled).toBe(true);
|
|
||||||
expect(result.methods).toContain('totp');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 3: Passkeys ─────────────────────────────
|
|
||||||
|
|
||||||
describe('listPasskeys', () => {
|
|
||||||
it('calls GET /auth/passkeys and unwraps response', async () => {
|
|
||||||
const data = {
|
|
||||||
passkeys: [
|
|
||||||
{
|
|
||||||
credentialId: 'pk1',
|
|
||||||
friendlyName: 'MacBook',
|
|
||||||
deviceType: 'singleDevice',
|
|
||||||
backedUp: true,
|
|
||||||
lastUsedAt: '2025-01-01T00:00:00Z',
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(data);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.listPasskeys();
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].friendlyName).toBe('MacBook');
|
|
||||||
expect(result[0].credentialId).toBe('pk1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyPasskeyAuth', () => {
|
|
||||||
it('stores tokens after passkey authentication', async () => {
|
|
||||||
const mockData = {
|
|
||||||
accessToken: 'pk-at',
|
|
||||||
refreshToken: 'pk-rt',
|
|
||||||
user: { id: 'u1', email: 'a@b.com', displayName: 'A', role: 'user', plan: 'free' },
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mockData);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.verifyPasskeyAuth({ id: 'cred-1', response: {} });
|
|
||||||
|
|
||||||
expect(result.user.email).toBe('a@b.com');
|
|
||||||
expect(client.getAccessToken()).toBe('pk-at');
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/passkeys/authenticate/verify');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 3: Devices ──────────────────────────────
|
|
||||||
|
|
||||||
describe('listDevices', () => {
|
|
||||||
it('calls GET /auth/devices and unwraps response', async () => {
|
|
||||||
const data = {
|
|
||||||
devices: [
|
|
||||||
{
|
|
||||||
fingerprint: 'fp-abc',
|
|
||||||
trustLevel: 'trusted',
|
|
||||||
deviceInfo: { platform: 'web' },
|
|
||||||
lastIp: '1.2.3.4',
|
|
||||||
trustExpiresAt: '2025-04-01T00:00:00Z',
|
|
||||||
createdAt: '2025-01-01T00:00:00Z',
|
|
||||||
lastSeenAt: '2025-01-01T00:00:00Z',
|
|
||||||
isTrusted: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(data);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.listDevices();
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].trustLevel).toBe('trusted');
|
|
||||||
expect(result[0].fingerprint).toBe('fp-abc');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('trustDevice', () => {
|
|
||||||
it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.trustDevice('fp-abc', 'trusted', { platform: 'web' });
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/devices/trust');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
const body = JSON.parse(opts.body);
|
|
||||||
expect(body.fingerprint).toBe('fp-abc');
|
|
||||||
expect(body.trustLevel).toBe('trusted');
|
|
||||||
expect(body.deviceInfo).toEqual({ platform: 'web' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revokeDevice', () => {
|
|
||||||
it('calls DELETE /auth/devices/:fingerprint', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.revokeDevice('fp-xyz');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz');
|
|
||||||
expect(opts.method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('revokeAllDevices', () => {
|
|
||||||
it('calls POST /auth/devices/revoke-all', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
await client.revokeAllDevices();
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 5B: Admin security ──────────────────────
|
|
||||||
|
|
||||||
describe('getSecurityOverview', () => {
|
|
||||||
it('calls GET /auth/security/overview', async () => {
|
|
||||||
const overview = {
|
|
||||||
totalUsers: 100,
|
|
||||||
mfaAdoptionPercent: 42.5,
|
|
||||||
providerDistribution: { google: 60, microsoft: 30, password: 10 },
|
|
||||||
activeSessions: 250,
|
|
||||||
suspiciousEvents24h: 3,
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(overview);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('admin-tok', 'admin-ref');
|
|
||||||
|
|
||||||
const result = await client.getSecurityOverview();
|
|
||||||
expect(result.totalUsers).toBe(100);
|
|
||||||
expect(result.mfaAdoptionPercent).toBe(42.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unlockUser', () => {
|
|
||||||
it('calls POST /auth/users/:id/unlock', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({});
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('admin-tok', 'admin-ref');
|
|
||||||
|
|
||||||
await client.unlockUser('user-locked-123');
|
|
||||||
|
|
||||||
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/auth/users/user-locked-123/unlock');
|
|
||||||
expect(opts.method).toBe('POST');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cancelDeletion', () => {
|
|
||||||
it('calls POST /auth/account/cancel-deletion', async () => {
|
|
||||||
globalThis.fetch = mockFetchResponse({ message: 'Deletion cancelled' });
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
client.setTokens('tok', 'ref');
|
|
||||||
|
|
||||||
const result = await client.cancelDeletion();
|
|
||||||
expect(result.message).toBe('Deletion cancelled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── login() MFA flow ──────────────────────────────
|
|
||||||
|
|
||||||
describe('login with MFA challenge', () => {
|
|
||||||
it('returns MfaLoginResult and does not store tokens', async () => {
|
|
||||||
const mfaResponse = {
|
|
||||||
mfaRequired: true,
|
|
||||||
mfaChallenge: 'login-challenge',
|
|
||||||
methods: ['totp', 'recovery'],
|
|
||||||
};
|
|
||||||
globalThis.fetch = mockFetchResponse(mfaResponse);
|
|
||||||
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.login('user@test.com', 'password');
|
|
||||||
|
|
||||||
expect('mfaRequired' in result).toBe(true);
|
|
||||||
if ('mfaRequired' in result) {
|
|
||||||
expect(result.mfaChallenge).toBe('login-challenge');
|
|
||||||
expect(result.methods).toEqual(['totp', 'recovery']);
|
|
||||||
}
|
|
||||||
expect(client.isAuthenticated()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── createAuthClient includes all SmartAuth methods ──
|
|
||||||
|
|
||||||
describe('client exposes all SmartAuth methods', () => {
|
|
||||||
it('has all phase 1C, 2D, 3, 5B methods', () => {
|
|
||||||
const client = createAuthClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
storage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Phase 1C
|
|
||||||
expect(client.loginWithGoogle).toBeTypeOf('function');
|
|
||||||
expect(client.loginWithMicrosoft).toBeTypeOf('function');
|
|
||||||
expect(client.loginWithApple).toBeTypeOf('function');
|
|
||||||
expect(client.getProviders).toBeTypeOf('function');
|
|
||||||
expect(client.linkProvider).toBeTypeOf('function');
|
|
||||||
expect(client.unlinkProvider).toBeTypeOf('function');
|
|
||||||
// Phase 2D
|
|
||||||
expect(client.verifyMfa).toBeTypeOf('function');
|
|
||||||
expect(client.setupTotp).toBeTypeOf('function');
|
|
||||||
expect(client.verifyTotpSetup).toBeTypeOf('function');
|
|
||||||
expect(client.disableMfa).toBeTypeOf('function');
|
|
||||||
expect(client.getMfaStatus).toBeTypeOf('function');
|
|
||||||
expect(client.regenerateRecoveryCodes).toBeTypeOf('function');
|
|
||||||
// Phase 3
|
|
||||||
expect(client.getPasskeyRegisterOptions).toBeTypeOf('function');
|
|
||||||
expect(client.verifyPasskeyRegistration).toBeTypeOf('function');
|
|
||||||
expect(client.getPasskeyAuthOptions).toBeTypeOf('function');
|
|
||||||
expect(client.verifyPasskeyAuth).toBeTypeOf('function');
|
|
||||||
expect(client.listPasskeys).toBeTypeOf('function');
|
|
||||||
expect(client.deletePasskey).toBeTypeOf('function');
|
|
||||||
expect(client.listDevices).toBeTypeOf('function');
|
|
||||||
expect(client.trustDevice).toBeTypeOf('function');
|
|
||||||
expect(client.revokeDevice).toBeTypeOf('function');
|
|
||||||
expect(client.revokeAllDevices).toBeTypeOf('function');
|
|
||||||
// Phase 5B
|
|
||||||
expect(client.getSecurityOverview).toBeTypeOf('function');
|
|
||||||
expect(client.unlockUser).toBeTypeOf('function');
|
|
||||||
expect(client.exportAuthData).toBeTypeOf('function');
|
|
||||||
expect(client.cancelDeletion).toBeTypeOf('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
527
vendor/bytelyst/auth-client/src/client.ts
vendored
527
vendor/bytelyst/auth-client/src/client.ts
vendored
@ -1,527 +0,0 @@
|
|||||||
/**
|
|
||||||
* Browser/React Native-safe auth API client for platform-service.
|
|
||||||
*
|
|
||||||
* Replaces hand-rolled auth clients in ChronoMind web, MindLyst web, NomGap, etc.
|
|
||||||
* No Node.js dependencies — uses globalThis.fetch and configurable storage.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { createAuthClient } from '@bytelyst/auth-client';
|
|
||||||
*
|
|
||||||
* const auth = createAuthClient({
|
|
||||||
* baseUrl: 'http://localhost:4003/api',
|
|
||||||
* productId: 'chronomind',
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const result = await auth.login('user@example.com', 'password123');
|
|
||||||
* console.log(result.user.displayName);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AuthClient,
|
|
||||||
AuthClientConfig,
|
|
||||||
AuthProvider,
|
|
||||||
AuthResult,
|
|
||||||
AuthUser,
|
|
||||||
Device,
|
|
||||||
LoginEventInfo,
|
|
||||||
MfaRequiredResult,
|
|
||||||
MfaStatus,
|
|
||||||
Passkey,
|
|
||||||
SecurityOverview,
|
|
||||||
TokenStorage,
|
|
||||||
TotpSetupResult,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
// ── Default localStorage adapter ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No-op storage fallback used when `localStorage` is unavailable (e.g. SSR / Node.js).
|
|
||||||
* Tokens stored via noopStorage are NOT persisted — they are lost on page reload.
|
|
||||||
* For server-side rendering, use cookie-based auth instead of relying on this client.
|
|
||||||
*/
|
|
||||||
const noopStorage: TokenStorage = {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {},
|
|
||||||
removeItem: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDefaultStorage(): TokenStorage {
|
|
||||||
if (
|
|
||||||
typeof globalThis.localStorage !== 'undefined' &&
|
|
||||||
typeof globalThis.localStorage?.getItem === 'function'
|
|
||||||
) {
|
|
||||||
return globalThis.localStorage;
|
|
||||||
}
|
|
||||||
return noopStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UUID helper (browser + RN safe) ──────────────────────────────
|
|
||||||
|
|
||||||
function uuid(): string {
|
|
||||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
||||||
return globalThis.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Factory ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function createAuthClient(config: AuthClientConfig): AuthClient {
|
|
||||||
const { baseUrl, productId, timeoutMs = 15_000 } = config;
|
|
||||||
const storage = config.storage ?? getDefaultStorage();
|
|
||||||
const prefix = config.storagePrefix ?? productId;
|
|
||||||
|
|
||||||
const KEYS = {
|
|
||||||
accessToken: `${prefix}-auth-token`,
|
|
||||||
refreshToken: `${prefix}-refresh-token`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ── Token management ────────────────────────────
|
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
|
||||||
return storage.getItem(KEYS.accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRefreshToken(): string | null {
|
|
||||||
return storage.getItem(KEYS.refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTokens(accessToken: string, refreshToken: string): void {
|
|
||||||
storage.setItem(KEYS.accessToken, accessToken);
|
|
||||||
storage.setItem(KEYS.refreshToken, refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearTokens(): void {
|
|
||||||
storage.removeItem(KEYS.accessToken);
|
|
||||||
storage.removeItem(KEYS.refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthenticated(): boolean {
|
|
||||||
return getAccessToken() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP helper ─────────────────────────────────
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
path: string,
|
|
||||||
method: string,
|
|
||||||
body?: unknown,
|
|
||||||
opts?: { skipAuth?: boolean }
|
|
||||||
): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-product-id': productId,
|
|
||||||
'x-request-id': uuid(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!opts?.skipAuth) {
|
|
||||||
const token = getAccessToken();
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
(data as Record<string, string>).message ||
|
|
||||||
(data as Record<string, string>).error ||
|
|
||||||
`HTTP ${res.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Singleton refresh guard ─────────────────────
|
|
||||||
|
|
||||||
let _refreshPromise: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
async function refreshAccessToken(): Promise<boolean> {
|
|
||||||
if (_refreshPromise) return _refreshPromise;
|
|
||||||
|
|
||||||
_refreshPromise = (async () => {
|
|
||||||
const rt = getRefreshToken();
|
|
||||||
if (!rt) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await request<{ accessToken: string; refreshToken: string }>(
|
|
||||||
'/auth/refresh',
|
|
||||||
'POST',
|
|
||||||
{ refreshToken: rt },
|
|
||||||
{ skipAuth: true }
|
|
||||||
);
|
|
||||||
setTokens(data.accessToken, data.refreshToken);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
clearTokens();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await _refreshPromise;
|
|
||||||
} finally {
|
|
||||||
_refreshPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auth operations ─────────────────────────────
|
|
||||||
|
|
||||||
async function login(email: string, password: string): Promise<AuthResult | MfaRequiredResult> {
|
|
||||||
const result = await request<AuthResult | MfaRequiredResult>('/auth/login', 'POST', {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
productId,
|
|
||||||
});
|
|
||||||
if ('mfaRequired' in result && result.mfaRequired) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const authResult = result as AuthResult;
|
|
||||||
setTokens(authResult.accessToken, authResult.refreshToken);
|
|
||||||
return authResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function register(
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
displayName: string
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const result = await request<AuthResult>('/auth/register', 'POST', {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
displayName,
|
|
||||||
productId,
|
|
||||||
});
|
|
||||||
setTokens(result.accessToken, result.refreshToken);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMe(): Promise<AuthUser> {
|
|
||||||
return request<AuthUser>('/auth/me', 'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Password management ─────────────────────────
|
|
||||||
|
|
||||||
async function forgotPassword(email: string): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/forgot-password', 'POST', {
|
|
||||||
email,
|
|
||||||
productId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/reset-password', 'POST', {
|
|
||||||
token,
|
|
||||||
newPassword,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changePassword(
|
|
||||||
currentPassword: string,
|
|
||||||
newPassword: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/change-password', 'POST', {
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Account management ──────────────────────────
|
|
||||||
|
|
||||||
async function deleteAccount(password: string): Promise<{ message: string }> {
|
|
||||||
const result = await request<{ message: string }>('/auth/account', 'DELETE', {
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
clearTokens();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Email verification ──────────────────────────
|
|
||||||
|
|
||||||
async function verifyEmail(token: string): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/verify-email', 'POST', { token });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerification(email: string): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/resend-verification', 'POST', {
|
|
||||||
email,
|
|
||||||
productId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OAuth / Social login (Phase 1C) ────────────────
|
|
||||||
|
|
||||||
async function loginWithOAuth(
|
|
||||||
provider: string,
|
|
||||||
idToken: string
|
|
||||||
): Promise<AuthResult | MfaRequiredResult> {
|
|
||||||
const result = await request<AuthResult | MfaRequiredResult>(
|
|
||||||
`/auth/oauth/${provider}`,
|
|
||||||
'POST',
|
|
||||||
{ idToken, productId },
|
|
||||||
{ skipAuth: true }
|
|
||||||
);
|
|
||||||
if ('mfaRequired' in result && result.mfaRequired) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const authResult = result as AuthResult;
|
|
||||||
setTokens(authResult.accessToken, authResult.refreshToken);
|
|
||||||
return authResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
|
||||||
return loginWithOAuth('google', idToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
|
||||||
return loginWithOAuth('microsoft', idToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult> {
|
|
||||||
return loginWithOAuth('apple', idToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Provider management (Phase 1C) ─────────────────
|
|
||||||
|
|
||||||
async function getProviders(): Promise<AuthProvider[]> {
|
|
||||||
const data = await request<{ providers: AuthProvider[] }>('/auth/providers', 'GET');
|
|
||||||
return data.providers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkProvider(provider: string, idToken: string): Promise<void> {
|
|
||||||
await request<void>('/auth/providers/link', 'POST', { provider, idToken });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unlinkProvider(provider: string): Promise<void> {
|
|
||||||
await request<void>(`/auth/providers/${provider}`, 'DELETE');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MFA (Phase 2D) ─────────────────────────────────
|
|
||||||
|
|
||||||
async function verifyMfa(
|
|
||||||
challengeToken: string,
|
|
||||||
code: string,
|
|
||||||
method: 'totp' | 'recovery'
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const result = await request<AuthResult>('/auth/mfa/verify', 'POST', {
|
|
||||||
challengeToken,
|
|
||||||
code,
|
|
||||||
method,
|
|
||||||
});
|
|
||||||
setTokens(result.accessToken, result.refreshToken);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupTotp(): Promise<TotpSetupResult> {
|
|
||||||
return request<TotpSetupResult>('/auth/mfa/setup', 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyTotpSetup(code: string): Promise<void> {
|
|
||||||
await request<void>('/auth/mfa/verify-setup', 'POST', { code });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableMfa(code: string): Promise<void> {
|
|
||||||
await request<void>('/auth/mfa/disable', 'POST', { code });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMfaStatus(): Promise<MfaStatus> {
|
|
||||||
return request<MfaStatus>('/auth/mfa/status', 'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function regenerateRecoveryCodes(): Promise<{ codes: string[] }> {
|
|
||||||
return request<{ codes: string[] }>('/auth/mfa/recovery/regenerate', 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passkeys (Phase 3) ─────────────────────────────
|
|
||||||
|
|
||||||
async function getPasskeyRegisterOptions(): Promise<unknown> {
|
|
||||||
return request<unknown>('/auth/passkeys/register/options', 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyPasskeyRegistration(response: unknown): Promise<void> {
|
|
||||||
await request<void>('/auth/passkeys/register/verify', 'POST', response);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPasskeyAuthOptions(): Promise<unknown> {
|
|
||||||
return request<unknown>('/auth/passkeys/authenticate/options', 'POST', undefined, {
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyPasskeyAuth(response: unknown): Promise<AuthResult> {
|
|
||||||
const result = await request<AuthResult>(
|
|
||||||
'/auth/passkeys/authenticate/verify',
|
|
||||||
'POST',
|
|
||||||
response,
|
|
||||||
{ skipAuth: true }
|
|
||||||
);
|
|
||||||
setTokens(result.accessToken, result.refreshToken);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPasskeys(): Promise<Passkey[]> {
|
|
||||||
const data = await request<{ passkeys: Passkey[] }>('/auth/passkeys', 'GET');
|
|
||||||
return data.passkeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePasskey(id: string): Promise<void> {
|
|
||||||
await request<void>(`/auth/passkeys/${id}`, 'DELETE');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Devices (Phase 3) ──────────────────────────────
|
|
||||||
|
|
||||||
async function listDevices(): Promise<Device[]> {
|
|
||||||
const data = await request<{ devices: Device[] }>('/auth/devices', 'GET');
|
|
||||||
return data.devices;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trustDevice(
|
|
||||||
fingerprint: string,
|
|
||||||
trustLevel: 'trusted' | 'remembered',
|
|
||||||
deviceInfo?: Record<string, string>
|
|
||||||
): Promise<void> {
|
|
||||||
await request<void>('/auth/devices/trust', 'POST', { fingerprint, trustLevel, deviceInfo });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeDevice(fingerprint: string): Promise<void> {
|
|
||||||
await request<void>(`/auth/devices/${fingerprint}`, 'DELETE');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeAllDevices(): Promise<void> {
|
|
||||||
await request<void>('/auth/devices/revoke-all', 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin security (Phase 5B) ──────────────────────
|
|
||||||
|
|
||||||
async function getSecurityOverview(): Promise<SecurityOverview> {
|
|
||||||
return request<SecurityOverview>('/auth/security/overview', 'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unlockUser(userId: string): Promise<void> {
|
|
||||||
await request<void>(`/auth/users/${userId}/unlock`, 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportAuthData(): Promise<unknown> {
|
|
||||||
return request<unknown>('/auth/export', 'GET');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelDeletion(): Promise<{ message: string }> {
|
|
||||||
return request<{ message: string }>('/auth/account/cancel-deletion', 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step-up auth ────────────────────────────────────
|
|
||||||
|
|
||||||
async function stepUp(method: string, credential: string): Promise<{ stepUpToken: string }> {
|
|
||||||
return request<{ stepUpToken: string }>('/auth/step-up', 'POST', { method, credential });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Login history ───────────────────────────────────
|
|
||||||
|
|
||||||
async function getLoginHistory(limit = 50): Promise<LoginEventInfo[]> {
|
|
||||||
const data = await request<{ events: LoginEventInfo[] }>(
|
|
||||||
`/auth/login-events?limit=${limit}`,
|
|
||||||
'GET'
|
|
||||||
);
|
|
||||||
return data.events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin security ──────────────────────────────────
|
|
||||||
|
|
||||||
async function getAdminLoginEvents(opts?: {
|
|
||||||
userId?: string;
|
|
||||||
suspicious?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<LoginEventInfo[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (opts?.userId) params.set('userId', opts.userId);
|
|
||||||
if (opts?.suspicious) params.set('suspicious', 'true');
|
|
||||||
if (opts?.limit) params.set('limit', String(opts.limit));
|
|
||||||
const qs = params.toString();
|
|
||||||
const data = await request<{ events: LoginEventInfo[] }>(
|
|
||||||
`/auth/login-events/admin${qs ? `?${qs}` : ''}`,
|
|
||||||
'GET'
|
|
||||||
);
|
|
||||||
return data.events;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAdminDevices(userId: string): Promise<Device[]> {
|
|
||||||
const data = await request<{ devices: Device[] }>(`/auth/devices/user/${userId}`, 'GET');
|
|
||||||
return data.devices;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getAccessToken,
|
|
||||||
getRefreshToken,
|
|
||||||
setTokens,
|
|
||||||
clearTokens,
|
|
||||||
isAuthenticated,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
getMe,
|
|
||||||
refreshAccessToken,
|
|
||||||
forgotPassword,
|
|
||||||
resetPassword,
|
|
||||||
changePassword,
|
|
||||||
deleteAccount,
|
|
||||||
verifyEmail,
|
|
||||||
resendVerification,
|
|
||||||
// OAuth / Social login (Phase 1C)
|
|
||||||
loginWithGoogle,
|
|
||||||
loginWithMicrosoft,
|
|
||||||
loginWithApple,
|
|
||||||
// Provider management (Phase 1C)
|
|
||||||
getProviders,
|
|
||||||
linkProvider,
|
|
||||||
unlinkProvider,
|
|
||||||
// MFA (Phase 2D)
|
|
||||||
verifyMfa,
|
|
||||||
setupTotp,
|
|
||||||
verifyTotpSetup,
|
|
||||||
disableMfa,
|
|
||||||
getMfaStatus,
|
|
||||||
regenerateRecoveryCodes,
|
|
||||||
// Passkeys (Phase 3)
|
|
||||||
getPasskeyRegisterOptions,
|
|
||||||
verifyPasskeyRegistration,
|
|
||||||
getPasskeyAuthOptions,
|
|
||||||
verifyPasskeyAuth,
|
|
||||||
listPasskeys,
|
|
||||||
deletePasskey,
|
|
||||||
// Devices (Phase 3)
|
|
||||||
listDevices,
|
|
||||||
trustDevice,
|
|
||||||
revokeDevice,
|
|
||||||
revokeAllDevices,
|
|
||||||
// Admin security (Phase 5B)
|
|
||||||
getSecurityOverview,
|
|
||||||
unlockUser,
|
|
||||||
exportAuthData,
|
|
||||||
cancelDeletion,
|
|
||||||
// Step-up auth
|
|
||||||
stepUp,
|
|
||||||
// Login history
|
|
||||||
getLoginHistory,
|
|
||||||
// Admin queries
|
|
||||||
getAdminLoginEvents,
|
|
||||||
getAdminDevices,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
16
vendor/bytelyst/auth-client/src/index.ts
vendored
16
vendor/bytelyst/auth-client/src/index.ts
vendored
@ -1,16 +0,0 @@
|
|||||||
export { createAuthClient } from './client.js';
|
|
||||||
export type {
|
|
||||||
AuthClient,
|
|
||||||
AuthClientConfig,
|
|
||||||
AuthProvider,
|
|
||||||
AuthResult,
|
|
||||||
AuthUser,
|
|
||||||
Device,
|
|
||||||
LoginEventInfo,
|
|
||||||
MfaRequiredResult,
|
|
||||||
MfaStatus,
|
|
||||||
Passkey,
|
|
||||||
SecurityOverview,
|
|
||||||
TokenStorage,
|
|
||||||
TotpSetupResult,
|
|
||||||
} from './types.js';
|
|
||||||
190
vendor/bytelyst/auth-client/src/types.ts
vendored
190
vendor/bytelyst/auth-client/src/types.ts
vendored
@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types for @bytelyst/auth-client.
|
|
||||||
* Browser/React Native-safe — no Node.js dependencies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface AuthClientConfig {
|
|
||||||
/** Platform-service base URL (e.g. "http://localhost:4003/api" or "https://api.example.com"). */
|
|
||||||
baseUrl: string;
|
|
||||||
|
|
||||||
/** Product identifier sent with every request as x-product-id header. */
|
|
||||||
productId: string;
|
|
||||||
|
|
||||||
/** Storage adapter for tokens. Defaults to localStorage if available. */
|
|
||||||
storage?: TokenStorage;
|
|
||||||
|
|
||||||
/** Optional prefix for storage keys. Default: product ID. */
|
|
||||||
storagePrefix?: string;
|
|
||||||
|
|
||||||
/** Request timeout in milliseconds. Default: 15000. */
|
|
||||||
timeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenStorage {
|
|
||||||
getItem(key: string): string | null;
|
|
||||||
setItem(key: string, value: string): void;
|
|
||||||
removeItem(key: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
displayName: string;
|
|
||||||
role: string;
|
|
||||||
plan: string;
|
|
||||||
mfaEnabled?: boolean;
|
|
||||||
mfaMethods?: string[];
|
|
||||||
providers?: string[];
|
|
||||||
products?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResult {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: AuthUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaRequiredResult {
|
|
||||||
mfaRequired: true;
|
|
||||||
mfaChallenge: string;
|
|
||||||
methods: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TotpSetupResult {
|
|
||||||
secret: string;
|
|
||||||
otpauthUri: string;
|
|
||||||
recoveryCodes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaStatus {
|
|
||||||
mfaEnabled: boolean;
|
|
||||||
methods: string[];
|
|
||||||
recoveryCodesRemaining: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthProvider {
|
|
||||||
provider: string;
|
|
||||||
email: string;
|
|
||||||
linkedAt: string;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Passkey {
|
|
||||||
credentialId: string;
|
|
||||||
friendlyName: string;
|
|
||||||
deviceType: string;
|
|
||||||
backedUp: boolean;
|
|
||||||
lastUsedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Device {
|
|
||||||
fingerprint: string;
|
|
||||||
trustLevel: 'trusted' | 'remembered' | 'unknown';
|
|
||||||
deviceInfo: Record<string, string | undefined>;
|
|
||||||
lastIp?: string;
|
|
||||||
lastLocation?: string;
|
|
||||||
trustExpiresAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
lastSeenAt: string;
|
|
||||||
isTrusted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginEventInfo {
|
|
||||||
id: string;
|
|
||||||
result: string;
|
|
||||||
method: string;
|
|
||||||
ip: string;
|
|
||||||
userAgent?: string;
|
|
||||||
fingerprint?: string;
|
|
||||||
location?: string;
|
|
||||||
riskLevel: string;
|
|
||||||
riskScore: number;
|
|
||||||
riskFlags: string[];
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityOverview {
|
|
||||||
totalUsers: number;
|
|
||||||
mfaAdoptionPercent: number;
|
|
||||||
providerDistribution: Record<string, number>;
|
|
||||||
activeSessions: number;
|
|
||||||
suspiciousEvents24h: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthClient {
|
|
||||||
// ── Token management ────────────────────────────
|
|
||||||
getAccessToken(): string | null;
|
|
||||||
getRefreshToken(): string | null;
|
|
||||||
setTokens(accessToken: string, refreshToken: string): void;
|
|
||||||
clearTokens(): void;
|
|
||||||
isAuthenticated(): boolean;
|
|
||||||
|
|
||||||
// ── Auth operations ─────────────────────────────
|
|
||||||
login(email: string, password: string): Promise<AuthResult | MfaRequiredResult>;
|
|
||||||
register(email: string, password: string, displayName: string): Promise<AuthResult>;
|
|
||||||
getMe(): Promise<AuthUser>;
|
|
||||||
refreshAccessToken(): Promise<boolean>;
|
|
||||||
|
|
||||||
// ── OAuth / Social login ────────────────────────
|
|
||||||
loginWithGoogle(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
|
||||||
loginWithMicrosoft(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
|
||||||
loginWithApple(idToken: string): Promise<AuthResult | MfaRequiredResult>;
|
|
||||||
|
|
||||||
// ── Provider management ─────────────────────────
|
|
||||||
getProviders(): Promise<AuthProvider[]>;
|
|
||||||
linkProvider(provider: string, idToken: string): Promise<void>;
|
|
||||||
unlinkProvider(provider: string): Promise<void>;
|
|
||||||
|
|
||||||
// ── MFA ─────────────────────────────────────────
|
|
||||||
verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise<AuthResult>;
|
|
||||||
setupTotp(): Promise<TotpSetupResult>;
|
|
||||||
verifyTotpSetup(code: string): Promise<void>;
|
|
||||||
disableMfa(code: string): Promise<void>;
|
|
||||||
getMfaStatus(): Promise<MfaStatus>;
|
|
||||||
regenerateRecoveryCodes(): Promise<{ codes: string[] }>;
|
|
||||||
|
|
||||||
// ── Passkeys (WebAuthn) ─────────────────────────
|
|
||||||
getPasskeyRegisterOptions(): Promise<unknown>;
|
|
||||||
verifyPasskeyRegistration(response: unknown): Promise<void>;
|
|
||||||
getPasskeyAuthOptions(): Promise<unknown>;
|
|
||||||
verifyPasskeyAuth(response: unknown): Promise<AuthResult>;
|
|
||||||
listPasskeys(): Promise<Passkey[]>;
|
|
||||||
deletePasskey(id: string): Promise<void>;
|
|
||||||
|
|
||||||
// ── Devices ─────────────────────────────────────
|
|
||||||
listDevices(): Promise<Device[]>;
|
|
||||||
trustDevice(
|
|
||||||
fingerprint: string,
|
|
||||||
trustLevel: 'trusted' | 'remembered',
|
|
||||||
deviceInfo?: Record<string, string>
|
|
||||||
): Promise<void>;
|
|
||||||
revokeDevice(fingerprint: string): Promise<void>;
|
|
||||||
revokeAllDevices(): Promise<void>;
|
|
||||||
|
|
||||||
// ── Step-up auth ────────────────────────────────
|
|
||||||
stepUp(method: string, credential: string): Promise<{ stepUpToken: string }>;
|
|
||||||
|
|
||||||
// ── Login history ───────────────────────────────
|
|
||||||
getLoginHistory(limit?: number): Promise<LoginEventInfo[]>;
|
|
||||||
|
|
||||||
// ── Admin security ──────────────────────────────
|
|
||||||
getSecurityOverview(): Promise<SecurityOverview>;
|
|
||||||
unlockUser(userId: string): Promise<void>;
|
|
||||||
getAdminLoginEvents(opts?: { suspicious?: boolean; limit?: number }): Promise<LoginEventInfo[]>;
|
|
||||||
getAdminDevices(userId: string): Promise<Device[]>;
|
|
||||||
exportAuthData(): Promise<unknown>;
|
|
||||||
|
|
||||||
// ── Password management ─────────────────────────
|
|
||||||
forgotPassword(email: string): Promise<{ message: string }>;
|
|
||||||
resetPassword(token: string, newPassword: string): Promise<{ message: string }>;
|
|
||||||
changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }>;
|
|
||||||
|
|
||||||
// ── Account management ──────────────────────────
|
|
||||||
deleteAccount(password: string): Promise<{ message: string }>;
|
|
||||||
cancelDeletion(): Promise<{ message: string }>;
|
|
||||||
|
|
||||||
// ── Email verification ──────────────────────────
|
|
||||||
verifyEmail(token: string): Promise<{ message: string }>;
|
|
||||||
resendVerification(email: string): Promise<{ message: string }>;
|
|
||||||
}
|
|
||||||
10
vendor/bytelyst/auth-client/tsconfig.json
vendored
10
vendor/bytelyst/auth-client/tsconfig.json
vendored
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"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
36
vendor/bytelyst/auth-ui/package.json
vendored
@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/auth-ui",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Shared auth UI components for SmartAuth (LoginForm, MfaChallenge, SocialButtons)",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18.0.0",
|
|
||||||
"react-dom": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"happy-dom": "^18.0.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx
vendored
101
vendor/bytelyst/auth-ui/src/AuthPageLayout.tsx
vendored
@ -1,101 +0,0 @@
|
|||||||
import type { AuthPageLayoutProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-page auth layout — centered card with product branding.
|
|
||||||
* Wraps any auth form (LoginForm, RegisterForm, etc.).
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function AuthPageLayout({
|
|
||||||
productName,
|
|
||||||
logo,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
className,
|
|
||||||
}: AuthPageLayoutProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
data-testid="bl-auth-page"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
minHeight: '100vh',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '16px',
|
|
||||||
background: 'var(--bl-page-bg, #f5f5f5)',
|
|
||||||
fontFamily: 'var(--bl-font, system-ui, -apple-system, sans-serif)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '400px',
|
|
||||||
background: 'var(--bl-surface, #fff)',
|
|
||||||
borderRadius: 'var(--bl-card-radius, 12px)',
|
|
||||||
boxShadow: 'var(--bl-card-shadow, 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06))',
|
|
||||||
padding: '32px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Branding */}
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
|
||||||
{logo && (
|
|
||||||
<div style={{ marginBottom: '12px' }} data-testid="bl-auth-logo">
|
|
||||||
{typeof logo === 'string' ? (
|
|
||||||
<img src={logo} alt={productName} style={{ height: '48px' }} />
|
|
||||||
) : (
|
|
||||||
logo
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '20px', fontWeight: 700, color: 'var(--bl-text, #111)' }}
|
|
||||||
data-testid="bl-auth-product-name"
|
|
||||||
>
|
|
||||||
{productName}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--bl-text, #333)',
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
data-testid="bl-auth-title"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{subtitle && (
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '14px', color: 'var(--bl-muted, #666)', marginTop: '4px' }}
|
|
||||||
data-testid="bl-auth-subtitle"
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form content */}
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{footer && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '20px',
|
|
||||||
paddingTop: '16px',
|
|
||||||
borderTop: '1px solid var(--bl-border, #eee)',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--bl-muted, #999)',
|
|
||||||
}}
|
|
||||||
data-testid="bl-auth-footer"
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
111
vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx
vendored
111
vendor/bytelyst/auth-ui/src/ForgotPasswordForm.tsx
vendored
@ -1,111 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import type { ForgotPasswordFormProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forgot password form — email input to request a reset link.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function ForgotPasswordForm({
|
|
||||||
onSubmit,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
onBack,
|
|
||||||
className,
|
|
||||||
}: ForgotPasswordFormProps) {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '14px',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-forgot-password-form">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
|
||||||
Enter your email address and we'll send you a link to reset your password.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-forgot-email"
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-forgot-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-forgot-success"
|
|
||||||
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || email.length === 0}
|
|
||||||
data-testid="bl-forgot-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: isLoading ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
data-testid="bl-forgot-back"
|
|
||||||
style={{
|
|
||||||
padding: '8px',
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--bl-link, #0066ff)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back to sign in
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
116
vendor/bytelyst/auth-ui/src/LoginForm.tsx
vendored
116
vendor/bytelyst/auth-ui/src/LoginForm.tsx
vendored
@ -1,116 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import { SocialButtons } from './SocialButtons.js';
|
|
||||||
import type { LoginFormProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email/password login form with optional social login buttons.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function LoginForm({
|
|
||||||
onSubmit,
|
|
||||||
providers,
|
|
||||||
onSocialLogin,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
className,
|
|
||||||
}: LoginFormProps) {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(email, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-login-form">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-login-email"
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-login-password"
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-login-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-login-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: isLoading ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{providers && providers.length > 0 && onSocialLogin && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
margin: '16px 0',
|
|
||||||
color: 'var(--bl-muted, #999)',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<hr
|
|
||||||
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
|
|
||||||
/>
|
|
||||||
or
|
|
||||||
<hr
|
|
||||||
style={{ flex: 1, border: 'none', borderTop: '1px solid var(--bl-border, #eee)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SocialButtons providers={providers} onSelect={onSocialLogin} disabled={isLoading} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
114
vendor/bytelyst/auth-ui/src/MfaChallenge.tsx
vendored
114
vendor/bytelyst/auth-ui/src/MfaChallenge.tsx
vendored
@ -1,114 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import type { MfaChallengeProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MFA code entry form (6-digit TOTP or recovery code).
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function MfaChallenge({
|
|
||||||
onSubmit,
|
|
||||||
onUseRecovery,
|
|
||||||
methods,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
className,
|
|
||||||
}: MfaChallengeProps) {
|
|
||||||
const [code, setCode] = useState('');
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-mfa-challenge">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
|
||||||
Enter your authentication code
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{methods && methods.length > 0 && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-mfa-methods"
|
|
||||||
style={{ fontSize: '12px', color: 'var(--bl-muted, #999)' }}
|
|
||||||
>
|
|
||||||
Available methods: {methods.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder="000000"
|
|
||||||
value={code}
|
|
||||||
onChange={e => setCode(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
maxLength={8}
|
|
||||||
data-testid="bl-mfa-code"
|
|
||||||
style={{
|
|
||||||
padding: '12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
letterSpacing: '4px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-mfa-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || code.length < 6}
|
|
||||||
data-testid="bl-mfa-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: isLoading || code.length < 6 ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Verifying...' : 'Verify'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onUseRecovery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onUseRecovery}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-mfa-recovery"
|
|
||||||
style={{
|
|
||||||
padding: '8px',
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--bl-link, #0066ff)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use a recovery code
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
148
vendor/bytelyst/auth-ui/src/OnboardingShell.tsx
vendored
148
vendor/bytelyst/auth-ui/src/OnboardingShell.tsx
vendored
@ -1,148 +0,0 @@
|
|||||||
import type { OnboardingShellProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Onboarding shell — step indicator, navigation, progress bar.
|
|
||||||
* Wraps arbitrary step content provided via children.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function OnboardingShell({
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
onComplete,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: OnboardingShellProps) {
|
|
||||||
const isFirst = currentStep === 0;
|
|
||||||
const isLast = currentStep === steps.length - 1;
|
|
||||||
const progress = steps.length > 1 ? ((currentStep + 1) / steps.length) * 100 : 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-onboarding-shell">
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '4px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
background: 'var(--bl-border, #e5e7eb)',
|
|
||||||
marginBottom: '24px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-testid="bl-onboarding-progress"
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${progress}%`,
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<div
|
|
||||||
data-testid="bl-onboarding-steps"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '8px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{steps.map((step, i) => (
|
|
||||||
<div
|
|
||||||
key={step.key}
|
|
||||||
data-testid={`bl-onboarding-step-${step.key}`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
fontSize: '13px',
|
|
||||||
color:
|
|
||||||
i === currentStep
|
|
||||||
? 'var(--bl-primary, #0066ff)'
|
|
||||||
: i < currentStep
|
|
||||||
? 'var(--bl-success, #22c55e)'
|
|
||||||
: 'var(--bl-muted, #999)',
|
|
||||||
fontWeight: i === currentStep ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
background:
|
|
||||||
i <= currentStep ? 'var(--bl-primary, #0066ff)' : 'var(--bl-border, #e5e7eb)',
|
|
||||||
color: i <= currentStep ? '#fff' : 'var(--bl-muted, #999)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i < currentStep ? '✓' : i + 1}
|
|
||||||
</span>
|
|
||||||
{step.label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div data-testid="bl-onboarding-content" style={{ marginBottom: '24px' }}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
disabled={isFirst}
|
|
||||||
data-testid="bl-onboarding-back"
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-surface, #fff)',
|
|
||||||
color: 'var(--bl-text, #333)',
|
|
||||||
cursor: isFirst ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
opacity: isFirst ? 0.4 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={isLast ? onComplete : onNext}
|
|
||||||
data-testid={isLast ? 'bl-onboarding-complete' : 'bl-onboarding-next'}
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLast ? 'Complete' : 'Next'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import type { PasswordStrength } from './types.js';
|
|
||||||
|
|
||||||
interface PasswordStrengthBarProps {
|
|
||||||
password: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRENGTH_CONFIG: Record<PasswordStrength, { color: string; label: string }> = {
|
|
||||||
weak: { color: 'var(--bl-error, #dc3545)', label: 'Weak' },
|
|
||||||
fair: { color: 'var(--bl-warning, #f59e0b)', label: 'Fair' },
|
|
||||||
good: { color: 'var(--bl-info, #3b82f6)', label: 'Good' },
|
|
||||||
strong: { color: 'var(--bl-success, #22c55e)', label: 'Strong' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getPasswordStrength(password: string): PasswordStrength {
|
|
||||||
let score = 0;
|
|
||||||
if (password.length >= 8) score++;
|
|
||||||
if (password.length >= 12) score++;
|
|
||||||
if (/[A-Z]/.test(password)) score++;
|
|
||||||
if (/[a-z]/.test(password)) score++;
|
|
||||||
if (/\d/.test(password)) score++;
|
|
||||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
|
||||||
|
|
||||||
if (score <= 2) return 'weak';
|
|
||||||
if (score <= 3) return 'fair';
|
|
||||||
if (score <= 4) return 'good';
|
|
||||||
return 'strong';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordStrengthBar({ password, className }: PasswordStrengthBarProps) {
|
|
||||||
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
|
||||||
const config = STRENGTH_CONFIG[strength];
|
|
||||||
const widthPercent = { weak: 25, fair: 50, good: 75, strong: 100 }[strength];
|
|
||||||
|
|
||||||
if (!password) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-password-strength">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '4px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
background: 'var(--bl-border, #e5e7eb)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
width: `${widthPercent}%`,
|
|
||||||
background: config.color,
|
|
||||||
transition: 'width 0.2s, background 0.2s',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
data-testid="bl-password-strength-fill"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{ fontSize: '12px', color: config.color, marginTop: '4px' }}
|
|
||||||
data-testid="bl-password-strength-label"
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
226
vendor/bytelyst/auth-ui/src/RegisterForm.tsx
vendored
226
vendor/bytelyst/auth-ui/src/RegisterForm.tsx
vendored
@ -1,226 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
|
||||||
import type { RegisterFormProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registration form with name, email, password, confirm password,
|
|
||||||
* password strength indicator, and optional terms checkbox.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function RegisterForm({
|
|
||||||
onSubmit,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
termsUrl,
|
|
||||||
privacyUrl,
|
|
||||||
onSwitchToLogin,
|
|
||||||
className,
|
|
||||||
}: RegisterFormProps) {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirm, setConfirm] = useState('');
|
|
||||||
const [termsAccepted, setTermsAccepted] = useState(!termsUrl);
|
|
||||||
|
|
||||||
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
|
||||||
const canSubmit =
|
|
||||||
name.trim().length > 0 &&
|
|
||||||
email.length > 0 &&
|
|
||||||
password.length >= 8 &&
|
|
||||||
!passwordMismatch &&
|
|
||||||
termsAccepted &&
|
|
||||||
!isLoading;
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!canSubmit) return;
|
|
||||||
onSubmit({ name: name.trim(), email, password });
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '14px',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-register-form">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Your name"
|
|
||||||
value={name}
|
|
||||||
onChange={e => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-register-name"
|
|
||||||
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
Email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={e => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-register-email"
|
|
||||||
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Min 8 characters"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-register-password"
|
|
||||||
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<PasswordStrengthBar password={password} />
|
|
||||||
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
Confirm password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Re-enter password"
|
|
||||||
value={confirm}
|
|
||||||
onChange={e => setConfirm(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-register-confirm"
|
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'block',
|
|
||||||
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{passwordMismatch && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-register-mismatch"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
|
||||||
>
|
|
||||||
Passwords do not match
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{termsUrl && (
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'var(--bl-text, #333)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={termsAccepted}
|
|
||||||
onChange={e => setTermsAccepted(e.target.checked)}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-register-terms"
|
|
||||||
style={{ marginTop: '2px' }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
I agree to the{' '}
|
|
||||||
<a
|
|
||||||
href={termsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--bl-link, #0066ff)' }}
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</a>
|
|
||||||
{privacyUrl && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
and{' '}
|
|
||||||
<a
|
|
||||||
href={privacyUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--bl-link, #0066ff)' }}
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-register-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
data-testid="bl-register-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: !canSubmit ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Creating account...' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onSwitchToLogin && (
|
|
||||||
<div style={{ textAlign: 'center', fontSize: '13px', color: 'var(--bl-muted, #999)' }}>
|
|
||||||
Already have an account?{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSwitchToLogin}
|
|
||||||
data-testid="bl-register-switch-login"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--bl-link, #0066ff)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
fontSize: '13px',
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
131
vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx
vendored
131
vendor/bytelyst/auth-ui/src/ResetPasswordForm.tsx
vendored
@ -1,131 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import { PasswordStrengthBar } from './PasswordStrengthBar.js';
|
|
||||||
import type { ResetPasswordFormProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset password form — new password + confirm, with strength indicator.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function ResetPasswordForm({
|
|
||||||
onSubmit,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
className,
|
|
||||||
}: ResetPasswordFormProps) {
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirm, setConfirm] = useState('');
|
|
||||||
|
|
||||||
const passwordMismatch = confirm.length > 0 && password !== confirm;
|
|
||||||
const canSubmit = password.length >= 8 && !passwordMismatch && !isLoading;
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!canSubmit) return;
|
|
||||||
onSubmit(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '14px',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-reset-password-form">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
|
||||||
Enter your new password.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
New password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Min 8 characters"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-reset-password"
|
|
||||||
style={{ ...inputStyle, marginTop: '4px', display: 'block' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<PasswordStrengthBar password={password} />
|
|
||||||
|
|
||||||
<label style={{ fontSize: '13px', fontWeight: 500, color: 'var(--bl-text, #333)' }}>
|
|
||||||
Confirm password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Re-enter password"
|
|
||||||
value={confirm}
|
|
||||||
onChange={e => setConfirm(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-reset-confirm"
|
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'block',
|
|
||||||
borderColor: passwordMismatch ? 'var(--bl-error, #dc3545)' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{passwordMismatch && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-reset-mismatch"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '12px' }}
|
|
||||||
>
|
|
||||||
Passwords do not match
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-reset-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-reset-success"
|
|
||||||
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
data-testid="bl-reset-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: !canSubmit ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: !canSubmit ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Updating...' : 'Update password'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
48
vendor/bytelyst/auth-ui/src/SocialButtons.tsx
vendored
48
vendor/bytelyst/auth-ui/src/SocialButtons.tsx
vendored
@ -1,48 +0,0 @@
|
|||||||
import type { SocialButtonsProps, SocialProvider } from './types.js';
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<SocialProvider, string> = {
|
|
||||||
google: 'Google',
|
|
||||||
microsoft: 'Microsoft',
|
|
||||||
apple: 'Apple',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders social login buttons for the configured providers.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function SocialButtons({
|
|
||||||
providers,
|
|
||||||
onSelect,
|
|
||||||
disabled = false,
|
|
||||||
className,
|
|
||||||
}: SocialButtonsProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
|
|
||||||
data-testid="bl-social-buttons"
|
|
||||||
>
|
|
||||||
{providers.map(provider => (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(provider)}
|
|
||||||
disabled={disabled}
|
|
||||||
data-testid={`bl-social-${provider}`}
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-surface, #fff)',
|
|
||||||
color: 'var(--bl-text, #333)',
|
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
opacity: disabled ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue with {PROVIDER_LABELS[provider]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
117
vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx
vendored
117
vendor/bytelyst/auth-ui/src/VerifyEmailForm.tsx
vendored
@ -1,117 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import type { VerifyEmailFormProps } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email verification form — 6-digit code input with resend option.
|
|
||||||
* Styled via CSS custom properties (inherits --bl-* from host app).
|
|
||||||
*/
|
|
||||||
export function VerifyEmailForm({
|
|
||||||
onSubmit,
|
|
||||||
onResend,
|
|
||||||
isLoading = false,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
email,
|
|
||||||
className,
|
|
||||||
}: VerifyEmailFormProps) {
|
|
||||||
const [code, setCode] = useState('');
|
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSubmit(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="bl-verify-email-form">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: '14px', color: 'var(--bl-text, #333)' }}>
|
|
||||||
Enter the 6-digit code sent to {email ? <strong>{email}</strong> : 'your email'}.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder="000000"
|
|
||||||
value={code}
|
|
||||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
|
||||||
maxLength={6}
|
|
||||||
data-testid="bl-verify-code"
|
|
||||||
style={{
|
|
||||||
padding: '12px',
|
|
||||||
border: '1px solid var(--bl-border, #ccc)',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
fontSize: '24px',
|
|
||||||
textAlign: 'center',
|
|
||||||
letterSpacing: '6px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-verify-error"
|
|
||||||
style={{ color: 'var(--bl-error, #dc3545)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div
|
|
||||||
data-testid="bl-verify-success"
|
|
||||||
style={{ color: 'var(--bl-success, #22c55e)', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || code.length < 6}
|
|
||||||
data-testid="bl-verify-submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 16px',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--bl-radius, 6px)',
|
|
||||||
background: 'var(--bl-primary, #0066ff)',
|
|
||||||
color: '#fff',
|
|
||||||
cursor: isLoading || code.length < 6 ? 'not-allowed' : 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
opacity: isLoading || code.length < 6 ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Verifying...' : 'Verify email'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onResend && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onResend}
|
|
||||||
disabled={isLoading}
|
|
||||||
data-testid="bl-verify-resend"
|
|
||||||
style={{
|
|
||||||
padding: '8px',
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--bl-link, #0066ff)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Resend code
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
||||||
import { LoginForm } from '../LoginForm.js';
|
|
||||||
import { MfaChallenge } from '../MfaChallenge.js';
|
|
||||||
import { SocialButtons } from '../SocialButtons.js';
|
|
||||||
|
|
||||||
describe('@bytelyst/auth-ui', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SocialButtons', () => {
|
|
||||||
it('renders buttons for each provider', () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
render(<SocialButtons providers={['google', 'microsoft', 'apple']} onSelect={onSelect} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-social-google')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-social-microsoft')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-social-apple')).toBeDefined();
|
|
||||||
expect(screen.getByText('Continue with Google')).toBeDefined();
|
|
||||||
expect(screen.getByText('Continue with Microsoft')).toBeDefined();
|
|
||||||
expect(screen.getByText('Continue with Apple')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSelect with provider when clicked', () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
render(<SocialButtons providers={['google']} onSelect={onSelect} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('bl-social-google'));
|
|
||||||
expect(onSelect).toHaveBeenCalledWith('google');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables buttons when disabled prop is true', () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
render(<SocialButtons providers={['google']} onSelect={onSelect} disabled />);
|
|
||||||
|
|
||||||
const btn = screen.getByTestId('bl-social-google');
|
|
||||||
expect(btn.getAttribute('disabled')).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('LoginForm', () => {
|
|
||||||
it('renders email, password, and submit button', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<LoginForm onSubmit={onSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-login-email')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-login-password')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-login-submit')).toBeDefined();
|
|
||||||
expect(screen.getByText('Sign in')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with email and password', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<LoginForm onSubmit={onSubmit} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('bl-login-email'), {
|
|
||||||
target: { value: 'test@example.com' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByTestId('bl-login-password'), {
|
|
||||||
target: { value: 'password123' },
|
|
||||||
});
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-login-submit').closest('form')!);
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error message', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<LoginForm onSubmit={onSubmit} error="Invalid credentials" />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-login-error')).toBeDefined();
|
|
||||||
expect(screen.getByText('Invalid credentials')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders social buttons when providers are given', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
const onSocialLogin = vi.fn();
|
|
||||||
render(
|
|
||||||
<LoginForm
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
providers={['google', 'apple']}
|
|
||||||
onSocialLogin={onSocialLogin}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-social-google')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-social-apple')).toBeDefined();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('bl-social-google'));
|
|
||||||
expect(onSocialLogin).toHaveBeenCalledWith('google');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading state', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<LoginForm onSubmit={onSubmit} isLoading />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Signing in...')).toBeDefined();
|
|
||||||
const btn = screen.getByTestId('bl-login-submit');
|
|
||||||
expect(btn.getAttribute('disabled')).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MfaChallenge', () => {
|
|
||||||
it('renders code input and verify button', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<MfaChallenge onSubmit={onSubmit} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-mfa-code')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-mfa-submit')).toBeDefined();
|
|
||||||
expect(screen.getByText('Verify')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with code', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<MfaChallenge onSubmit={onSubmit} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('bl-mfa-code'), {
|
|
||||||
target: { value: '123456' },
|
|
||||||
});
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-mfa-submit').closest('form')!);
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith('123456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays available methods', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<MfaChallenge onSubmit={onSubmit} methods={['totp', 'recovery']} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-mfa-methods')).toBeDefined();
|
|
||||||
expect(screen.getByText('Available methods: totp, recovery')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows recovery code button when handler provided', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
const onUseRecovery = vi.fn();
|
|
||||||
render(<MfaChallenge onSubmit={onSubmit} onUseRecovery={onUseRecovery} />);
|
|
||||||
|
|
||||||
const recoveryBtn = screen.getByTestId('bl-mfa-recovery');
|
|
||||||
expect(recoveryBtn).toBeDefined();
|
|
||||||
|
|
||||||
fireEvent.click(recoveryBtn);
|
|
||||||
expect(onUseRecovery).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error message', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<MfaChallenge onSubmit={onSubmit} error="Invalid code" />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-mfa-error')).toBeDefined();
|
|
||||||
expect(screen.getByText('Invalid code')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
|
||||||
import { RegisterForm } from '../RegisterForm.js';
|
|
||||||
import { ForgotPasswordForm } from '../ForgotPasswordForm.js';
|
|
||||||
import { ResetPasswordForm } from '../ResetPasswordForm.js';
|
|
||||||
import { VerifyEmailForm } from '../VerifyEmailForm.js';
|
|
||||||
import { OnboardingShell } from '../OnboardingShell.js';
|
|
||||||
import { AuthPageLayout } from '../AuthPageLayout.js';
|
|
||||||
import { PasswordStrengthBar, getPasswordStrength } from '../PasswordStrengthBar.js';
|
|
||||||
|
|
||||||
describe('RegisterForm', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('renders all fields', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('bl-register-name')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-register-email')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-register-password')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-register-confirm')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-register-submit')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with name, email, password', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<RegisterForm onSubmit={onSubmit} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-name'), { target: { value: 'Alice' } });
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-email'), {
|
|
||||||
target: { value: 'alice@example.com' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
|
||||||
target: { value: 'Password1!' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
|
||||||
target: { value: 'Password1!' },
|
|
||||||
});
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-register-submit').closest('form')!);
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith({
|
|
||||||
name: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
password: 'Password1!',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows password mismatch error', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-password'), {
|
|
||||||
target: { value: 'Password1!' },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-confirm'), {
|
|
||||||
target: { value: 'Different1!' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId('bl-register-mismatch')).toBeDefined();
|
|
||||||
expect(screen.getByText('Passwords do not match')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error message', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} error="Email already taken" />);
|
|
||||||
expect(screen.getByTestId('bl-register-error')).toBeDefined();
|
|
||||||
expect(screen.getByText('Email already taken')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows terms checkbox when termsUrl provided', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} termsUrl="https://example.com/terms" />);
|
|
||||||
expect(screen.getByTestId('bl-register-terms')).toBeDefined();
|
|
||||||
expect(screen.getByText('Terms of Service')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders switch to login link', () => {
|
|
||||||
const onSwitch = vi.fn();
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} onSwitchToLogin={onSwitch} />);
|
|
||||||
const link = screen.getByTestId('bl-register-switch-login');
|
|
||||||
fireEvent.click(link);
|
|
||||||
expect(onSwitch).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading state', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} isLoading />);
|
|
||||||
expect(screen.getByText('Creating account...')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows password strength bar when typing', () => {
|
|
||||||
render(<RegisterForm onSubmit={vi.fn()} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-register-password'), { target: { value: 'ab' } });
|
|
||||||
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ForgotPasswordForm', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('renders email input and submit', () => {
|
|
||||||
render(<ForgotPasswordForm onSubmit={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('bl-forgot-email')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-forgot-submit')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with email', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<ForgotPasswordForm onSubmit={onSubmit} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-forgot-email'), {
|
|
||||||
target: { value: 'test@example.com' },
|
|
||||||
});
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-forgot-submit').closest('form')!);
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith('test@example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error message', () => {
|
|
||||||
render(<ForgotPasswordForm onSubmit={vi.fn()} error="Email not found" />);
|
|
||||||
expect(screen.getByTestId('bl-forgot-error')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays success message', () => {
|
|
||||||
render(<ForgotPasswordForm onSubmit={vi.fn()} success="Check your email" />);
|
|
||||||
expect(screen.getByTestId('bl-forgot-success')).toBeDefined();
|
|
||||||
expect(screen.getByText('Check your email')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders back button and calls onBack', () => {
|
|
||||||
const onBack = vi.fn();
|
|
||||||
render(<ForgotPasswordForm onSubmit={vi.fn()} onBack={onBack} />);
|
|
||||||
fireEvent.click(screen.getByTestId('bl-forgot-back'));
|
|
||||||
expect(onBack).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading state', () => {
|
|
||||||
render(<ForgotPasswordForm onSubmit={vi.fn()} isLoading />);
|
|
||||||
expect(screen.getByText('Sending...')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResetPasswordForm', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('renders password fields and submit', () => {
|
|
||||||
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('bl-reset-password')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-reset-confirm')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-reset-submit')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with password', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<ResetPasswordForm onSubmit={onSubmit} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
|
||||||
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'NewPass1!' } });
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-reset-submit').closest('form')!);
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith('NewPass1!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows mismatch error', () => {
|
|
||||||
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'NewPass1!' } });
|
|
||||||
fireEvent.change(screen.getByTestId('bl-reset-confirm'), { target: { value: 'Different!' } });
|
|
||||||
expect(screen.getByTestId('bl-reset-mismatch')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error and success messages', () => {
|
|
||||||
const { rerender } = render(<ResetPasswordForm onSubmit={vi.fn()} error="Token expired" />);
|
|
||||||
expect(screen.getByTestId('bl-reset-error')).toBeDefined();
|
|
||||||
|
|
||||||
rerender(<ResetPasswordForm onSubmit={vi.fn()} success="Password updated" />);
|
|
||||||
expect(screen.getByTestId('bl-reset-success')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows password strength bar', () => {
|
|
||||||
render(<ResetPasswordForm onSubmit={vi.fn()} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-reset-password'), { target: { value: 'abc' } });
|
|
||||||
expect(screen.getByTestId('bl-password-strength')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('VerifyEmailForm', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('renders code input and submit', () => {
|
|
||||||
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('bl-verify-code')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-verify-submit')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSubmit with code', () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(<VerifyEmailForm onSubmit={onSubmit} />);
|
|
||||||
fireEvent.change(screen.getByTestId('bl-verify-code'), { target: { value: '123456' } });
|
|
||||||
fireEvent.submit(screen.getByTestId('bl-verify-submit').closest('form')!);
|
|
||||||
expect(onSubmit).toHaveBeenCalledWith('123456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays email address', () => {
|
|
||||||
render(<VerifyEmailForm onSubmit={vi.fn()} email="test@example.com" />);
|
|
||||||
expect(screen.getByText('test@example.com')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders resend button', () => {
|
|
||||||
const onResend = vi.fn();
|
|
||||||
render(<VerifyEmailForm onSubmit={vi.fn()} onResend={onResend} />);
|
|
||||||
fireEvent.click(screen.getByTestId('bl-verify-resend'));
|
|
||||||
expect(onResend).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error and success messages', () => {
|
|
||||||
const { rerender } = render(<VerifyEmailForm onSubmit={vi.fn()} error="Invalid code" />);
|
|
||||||
expect(screen.getByTestId('bl-verify-error')).toBeDefined();
|
|
||||||
|
|
||||||
rerender(<VerifyEmailForm onSubmit={vi.fn()} success="Code resent" />);
|
|
||||||
expect(screen.getByTestId('bl-verify-success')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips non-numeric characters', () => {
|
|
||||||
render(<VerifyEmailForm onSubmit={vi.fn()} />);
|
|
||||||
const input = screen.getByTestId('bl-verify-code');
|
|
||||||
fireEvent.change(input, { target: { value: 'abc123def456' } });
|
|
||||||
expect((input as unknown as { value: string }).value).toBe('123456');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OnboardingShell', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ key: 'welcome', label: 'Welcome' },
|
|
||||||
{ key: 'profile', label: 'Profile' },
|
|
||||||
{ key: 'preferences', label: 'Preferences' },
|
|
||||||
];
|
|
||||||
|
|
||||||
it('renders steps and content', () => {
|
|
||||||
render(
|
|
||||||
<OnboardingShell
|
|
||||||
steps={steps}
|
|
||||||
currentStep={0}
|
|
||||||
onNext={vi.fn()}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
onComplete={vi.fn()}
|
|
||||||
>
|
|
||||||
<div>Step 1 content</div>
|
|
||||||
</OnboardingShell>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('bl-onboarding-shell')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-onboarding-steps')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('bl-onboarding-progress')).toBeDefined();
|
|
||||||
expect(screen.getByText('Step 1 content')).toBeDefined();
|
|
||||||
expect(screen.getByText('Welcome')).toBeDefined();
|
|
||||||
expect(screen.getByText('Profile')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables Back on first step', () => {
|
|
||||||
render(
|
|
||||||
<OnboardingShell
|
|
||||||
steps={steps}
|
|
||||||
currentStep={0}
|
|
||||||
onNext={vi.fn()}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
onComplete={vi.fn()}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</OnboardingShell>
|
|
||||||
);
|
|
||||||
const back = screen.getByTestId('bl-onboarding-back');
|
|
||||||
expect(back.getAttribute('disabled')).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onNext on middle step', () => {
|
|
||||||
const onNext = vi.fn();
|
|
||||||
render(
|
|
||||||
<OnboardingShell
|
|
||||||
steps={steps}
|
|
||||||
currentStep={1}
|
|
||||||
onNext={onNext}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
onComplete={vi.fn()}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</OnboardingShell>
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByTestId('bl-onboarding-next'));
|
|
||||||
expect(onNext).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Complete on last step and calls onComplete', () => {
|
|
||||||
const onComplete = vi.fn();
|
|
||||||
render(
|
|
||||||
<OnboardingShell
|
|
||||||
steps={steps}
|
|
||||||
currentStep={2}
|
|
||||||
onNext={vi.fn()}
|
|
||||||
onBack={vi.fn()}
|
|
||||||
onComplete={onComplete}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</OnboardingShell>
|
|
||||||
);
|
|
||||||
const btn = screen.getByTestId('bl-onboarding-complete');
|
|
||||||
expect(btn.textContent).toBe('Complete');
|
|
||||||
fireEvent.click(btn);
|
|
||||||
expect(onComplete).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onBack on non-first step', () => {
|
|
||||||
const onBack = vi.fn();
|
|
||||||
render(
|
|
||||||
<OnboardingShell
|
|
||||||
steps={steps}
|
|
||||||
currentStep={1}
|
|
||||||
onNext={vi.fn()}
|
|
||||||
onBack={onBack}
|
|
||||||
onComplete={vi.fn()}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</OnboardingShell>
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByTestId('bl-onboarding-back'));
|
|
||||||
expect(onBack).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AuthPageLayout', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('renders product name and title', () => {
|
|
||||||
render(
|
|
||||||
<AuthPageLayout productName="TestApp" title="Sign In">
|
|
||||||
<div>Form content</div>
|
|
||||||
</AuthPageLayout>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('bl-auth-product-name').textContent).toBe('TestApp');
|
|
||||||
expect(screen.getByTestId('bl-auth-title').textContent).toBe('Sign In');
|
|
||||||
expect(screen.getByText('Form content')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders subtitle when provided', () => {
|
|
||||||
render(
|
|
||||||
<AuthPageLayout productName="TestApp" title="Sign In" subtitle="Welcome back">
|
|
||||||
<div />
|
|
||||||
</AuthPageLayout>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('bl-auth-subtitle').textContent).toBe('Welcome back');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders logo as element', () => {
|
|
||||||
render(
|
|
||||||
<AuthPageLayout
|
|
||||||
productName="TestApp"
|
|
||||||
title="Sign In"
|
|
||||||
logo={<span data-testid="custom-logo">Logo</span>}
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
</AuthPageLayout>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('custom-logo')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders footer', () => {
|
|
||||||
render(
|
|
||||||
<AuthPageLayout productName="TestApp" title="Sign In" footer={<span>Footer text</span>}>
|
|
||||||
<div />
|
|
||||||
</AuthPageLayout>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('bl-auth-footer')).toBeDefined();
|
|
||||||
expect(screen.getByText('Footer text')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PasswordStrengthBar', () => {
|
|
||||||
beforeEach(() => cleanup());
|
|
||||||
|
|
||||||
it('returns null for empty password', () => {
|
|
||||||
const { container } = render(<PasswordStrengthBar password="" />);
|
|
||||||
expect(container.querySelector('[data-testid="bl-password-strength"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Weak for short password', () => {
|
|
||||||
render(<PasswordStrengthBar password="ab" />);
|
|
||||||
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Weak');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Strong for complex password', () => {
|
|
||||||
render(<PasswordStrengthBar password="MyStr0ng!Pass" />);
|
|
||||||
expect(screen.getByTestId('bl-password-strength-label').textContent).toBe('Strong');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getPasswordStrength', () => {
|
|
||||||
it('returns weak for very short passwords', () => {
|
|
||||||
expect(getPasswordStrength('ab')).toBe('weak');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fair for medium passwords', () => {
|
|
||||||
expect(getPasswordStrength('abcdefgh1')).toBe('fair');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns good for decent passwords', () => {
|
|
||||||
expect(getPasswordStrength('Abcdefgh1')).toBe('good');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns strong for complex passwords', () => {
|
|
||||||
expect(getPasswordStrength('MyStr0ng!Pass')).toBe('strong');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
24
vendor/bytelyst/auth-ui/src/index.ts
vendored
24
vendor/bytelyst/auth-ui/src/index.ts
vendored
@ -1,24 +0,0 @@
|
|||||||
export { LoginForm } from './LoginForm.js';
|
|
||||||
export { RegisterForm } from './RegisterForm.js';
|
|
||||||
export { ForgotPasswordForm } from './ForgotPasswordForm.js';
|
|
||||||
export { ResetPasswordForm } from './ResetPasswordForm.js';
|
|
||||||
export { VerifyEmailForm } from './VerifyEmailForm.js';
|
|
||||||
export { MfaChallenge } from './MfaChallenge.js';
|
|
||||||
export { SocialButtons } from './SocialButtons.js';
|
|
||||||
export { OnboardingShell } from './OnboardingShell.js';
|
|
||||||
export { AuthPageLayout } from './AuthPageLayout.js';
|
|
||||||
export { PasswordStrengthBar, getPasswordStrength } from './PasswordStrengthBar.js';
|
|
||||||
export type {
|
|
||||||
LoginFormProps,
|
|
||||||
RegisterFormProps,
|
|
||||||
ForgotPasswordFormProps,
|
|
||||||
ResetPasswordFormProps,
|
|
||||||
VerifyEmailFormProps,
|
|
||||||
MfaChallengeProps,
|
|
||||||
SocialButtonsProps,
|
|
||||||
SocialProvider,
|
|
||||||
OnboardingShellProps,
|
|
||||||
OnboardingStep,
|
|
||||||
AuthPageLayoutProps,
|
|
||||||
PasswordStrength,
|
|
||||||
} from './types.js';
|
|
||||||
147
vendor/bytelyst/auth-ui/src/types.ts
vendored
147
vendor/bytelyst/auth-ui/src/types.ts
vendored
@ -1,147 +0,0 @@
|
|||||||
export type SocialProvider = 'google' | 'microsoft' | 'apple';
|
|
||||||
|
|
||||||
export interface LoginFormProps {
|
|
||||||
/** Called when user submits email/password. */
|
|
||||||
onSubmit: (email: string, password: string) => void;
|
|
||||||
/** Social providers to display. */
|
|
||||||
providers?: SocialProvider[];
|
|
||||||
/** Called when user clicks a social login button. */
|
|
||||||
onSocialLogin?: (provider: SocialProvider) => void;
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaChallengeProps {
|
|
||||||
/** Called when user submits the MFA code. */
|
|
||||||
onSubmit: (code: string) => void;
|
|
||||||
/** Called when user clicks "Use recovery code". */
|
|
||||||
onUseRecovery?: () => void;
|
|
||||||
/** MFA methods available. */
|
|
||||||
methods?: string[];
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SocialButtonsProps {
|
|
||||||
/** Providers to display buttons for. */
|
|
||||||
providers: SocialProvider[];
|
|
||||||
/** Called when a provider button is clicked. */
|
|
||||||
onSelect: (provider: SocialProvider) => void;
|
|
||||||
/** Whether the buttons are disabled. */
|
|
||||||
disabled?: boolean;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterFormProps {
|
|
||||||
/** Called when user submits registration. */
|
|
||||||
onSubmit: (data: { name: string; email: string; password: string }) => void;
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Terms of service URL (renders checkbox if provided). */
|
|
||||||
termsUrl?: string;
|
|
||||||
/** Privacy policy URL. */
|
|
||||||
privacyUrl?: string;
|
|
||||||
/** Called when user clicks "Already have an account?" */
|
|
||||||
onSwitchToLogin?: () => void;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForgotPasswordFormProps {
|
|
||||||
/** Called when user submits email for password reset. */
|
|
||||||
onSubmit: (email: string) => void;
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Success message (e.g., "Check your email"). */
|
|
||||||
success?: string | null;
|
|
||||||
/** Called when user clicks "Back to login". */
|
|
||||||
onBack?: () => void;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResetPasswordFormProps {
|
|
||||||
/** Called when user submits new password. */
|
|
||||||
onSubmit: (password: string) => void;
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Success message (e.g., "Password updated"). */
|
|
||||||
success?: string | null;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyEmailFormProps {
|
|
||||||
/** Called when user submits the verification code. */
|
|
||||||
onSubmit: (code: string) => void;
|
|
||||||
/** Called when user clicks "Resend code". */
|
|
||||||
onResend?: () => void;
|
|
||||||
/** Whether the form is currently loading. */
|
|
||||||
isLoading?: boolean;
|
|
||||||
/** Error message to display. */
|
|
||||||
error?: string | null;
|
|
||||||
/** Success message (e.g., "Code resent"). */
|
|
||||||
success?: string | null;
|
|
||||||
/** Email address being verified (for display). */
|
|
||||||
email?: string;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnboardingStep {
|
|
||||||
/** Unique key for the step. */
|
|
||||||
key: string;
|
|
||||||
/** Display label for the step indicator. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnboardingShellProps {
|
|
||||||
/** Ordered list of steps. */
|
|
||||||
steps: OnboardingStep[];
|
|
||||||
/** Index of the current step (0-based). */
|
|
||||||
currentStep: number;
|
|
||||||
/** Called when user clicks Next. */
|
|
||||||
onNext: () => void;
|
|
||||||
/** Called when user clicks Back. */
|
|
||||||
onBack: () => void;
|
|
||||||
/** Called when the final step completes. */
|
|
||||||
onComplete: () => void;
|
|
||||||
/** Content to render for the current step. */
|
|
||||||
children: React.ReactNode;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthPageLayoutProps {
|
|
||||||
/** Product name displayed at the top. */
|
|
||||||
productName: string;
|
|
||||||
/** Optional logo element or URL. */
|
|
||||||
logo?: React.ReactNode;
|
|
||||||
/** Page title (e.g., "Sign In", "Create Account"). */
|
|
||||||
title: string;
|
|
||||||
/** Subtitle or description. */
|
|
||||||
subtitle?: string;
|
|
||||||
/** Form content. */
|
|
||||||
children: React.ReactNode;
|
|
||||||
/** Footer content (links, etc.). */
|
|
||||||
footer?: React.ReactNode;
|
|
||||||
/** Additional CSS class for the root element. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PasswordStrength = 'weak' | 'fair' | 'good' | 'strong';
|
|
||||||
11
vendor/bytelyst/auth-ui/tsconfig.json
vendored
11
vendor/bytelyst/auth-ui/tsconfig.json
vendored
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"lib": ["ES2022", "DOM"],
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
|
||||||
}
|
|
||||||
8
vendor/bytelyst/auth-ui/vitest.config.ts
vendored
8
vendor/bytelyst/auth-ui/vitest.config.ts
vendored
@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'happy-dom',
|
|
||||||
pool: 'forks',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
30
vendor/bytelyst/auth/package.json
vendored
30
vendor/bytelyst/auth/package.json
vendored
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/auth",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@bytelyst/errors": "workspace:*"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"jose": ">=5.0.0",
|
|
||||||
"bcryptjs": ">=2.4.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
137
vendor/bytelyst/auth/src/__tests__/auth.test.ts
vendored
137
vendor/bytelyst/auth/src/__tests__/auth.test.ts
vendored
@ -1,137 +0,0 @@
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { createJwtUtils, hashPassword, verifyPassword } from '../index.js';
|
|
||||||
|
|
||||||
describe('JWT utilities', () => {
|
|
||||||
const SECRET = 'test-jwt-secret-at-least-32-chars-long!!';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.JWT_SECRET = SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates and verifies an access token', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'test-issuer' });
|
|
||||||
const token = await jwt.createAccessToken({
|
|
||||||
sub: 'user-1',
|
|
||||||
email: 'test@example.com',
|
|
||||||
role: 'admin',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(typeof token).toBe('string');
|
|
||||||
expect(token.split('.')).toHaveLength(3);
|
|
||||||
|
|
||||||
const payload = await jwt.verifyToken(token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('user-1');
|
|
||||||
expect(payload!.email).toBe('test@example.com');
|
|
||||||
expect(payload!.role).toBe('admin');
|
|
||||||
expect(payload!.type).toBe('access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates and verifies a refresh token', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'test-issuer' });
|
|
||||||
const token = await jwt.createRefreshToken({ sub: 'user-1' });
|
|
||||||
|
|
||||||
const payload = await jwt.verifyToken(token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('user-1');
|
|
||||||
expect(payload!.type).toBe('refresh');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for invalid token', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'test-issuer' });
|
|
||||||
const result = await jwt.verifyToken('garbage.not.valid');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for wrong issuer', async () => {
|
|
||||||
const jwt1 = createJwtUtils({ issuer: 'issuer-a' });
|
|
||||||
const jwt2 = createJwtUtils({ issuer: 'issuer-b' });
|
|
||||||
|
|
||||||
const token = await jwt1.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await jwt2.verifyToken(token);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets productId from payload or defaults to issuer', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'lysnrai' });
|
|
||||||
|
|
||||||
const t1 = await jwt.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
const p1 = await jwt.verifyToken(t1);
|
|
||||||
expect(p1!.productId).toBe('lysnrai');
|
|
||||||
|
|
||||||
const t2 = await jwt.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
productId: 'mindlyst',
|
|
||||||
});
|
|
||||||
const p2 = await jwt.verifyToken(t2);
|
|
||||||
expect(p2!.productId).toBe('mindlyst');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects custom expiry', async () => {
|
|
||||||
const jwt = createJwtUtils({
|
|
||||||
issuer: 'test',
|
|
||||||
accessTokenExpiry: '2h',
|
|
||||||
refreshTokenExpiry: '7d',
|
|
||||||
});
|
|
||||||
|
|
||||||
const access = await jwt.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
const refresh = await jwt.createRefreshToken({ sub: 'u1' });
|
|
||||||
|
|
||||||
expect(typeof access).toBe('string');
|
|
||||||
expect(typeof refresh).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when JWT_SECRET is not set', async () => {
|
|
||||||
const origSecret = process.env.JWT_SECRET;
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
|
|
||||||
const jwt = createJwtUtils({ issuer: 'test' });
|
|
||||||
await expect(
|
|
||||||
jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' })
|
|
||||||
).rejects.toThrow('JWT_SECRET must be set');
|
|
||||||
|
|
||||||
process.env.JWT_SECRET = origSecret;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('password hashing', () => {
|
|
||||||
it('hashes a password and verifies it', async () => {
|
|
||||||
const hash = await hashPassword('MySecret123!');
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
expect(hash).not.toBe('MySecret123!');
|
|
||||||
|
|
||||||
const valid = await verifyPassword('MySecret123!', hash);
|
|
||||||
expect(valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects wrong password', async () => {
|
|
||||||
const hash = await hashPassword('correct-password');
|
|
||||||
const valid = await verifyPassword('wrong-password', hash);
|
|
||||||
expect(valid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces different hashes for same input', async () => {
|
|
||||||
const h1 = await hashPassword('same');
|
|
||||||
const h2 = await hashPassword('same');
|
|
||||||
expect(h1).not.toBe(h2); // different salts
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* End-to-end auth flow test: create token → extract auth → require role.
|
|
||||||
* Exercises the full JWT lifecycle without network calls.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
||||||
import {
|
|
||||||
createJwtUtils,
|
|
||||||
extractAuth,
|
|
||||||
requireRole,
|
|
||||||
hashPassword,
|
|
||||||
verifyPassword,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!';
|
|
||||||
|
|
||||||
describe('E2E auth flow', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.JWT_SECRET = SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('full flow: login credentials → JWT → authenticated request → role check', async () => {
|
|
||||||
// 1. Simulate password verification (login step)
|
|
||||||
const storedHash = await hashPassword('SecretPass123!');
|
|
||||||
const passwordValid = await verifyPassword('SecretPass123!', storedHash);
|
|
||||||
expect(passwordValid).toBe(true);
|
|
||||||
|
|
||||||
// 2. Issue access token (platform-service would do this)
|
|
||||||
const jwt = createJwtUtils({ issuer: 'lysnrai' });
|
|
||||||
const accessToken = await jwt.createAccessToken({
|
|
||||||
sub: 'user-admin-001',
|
|
||||||
email: 'admin@lysnrai.com',
|
|
||||||
role: 'super_admin',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
});
|
|
||||||
expect(typeof accessToken).toBe('string');
|
|
||||||
|
|
||||||
// 3. Simulate authenticated request (any service receives this)
|
|
||||||
const req = { headers: { authorization: `Bearer ${accessToken}` } };
|
|
||||||
const auth = await extractAuth(req);
|
|
||||||
expect(auth.sub).toBe('user-admin-001');
|
|
||||||
expect(auth.email).toBe('admin@lysnrai.com');
|
|
||||||
expect(auth.role).toBe('super_admin');
|
|
||||||
expect(auth.productId).toBe('lysnrai');
|
|
||||||
|
|
||||||
// 4. Role-gated endpoint check
|
|
||||||
const adminAuth = await requireRole(req, 'super_admin', 'admin');
|
|
||||||
expect(adminAuth.sub).toBe('user-admin-001');
|
|
||||||
|
|
||||||
// 5. Role rejection for wrong role
|
|
||||||
await expect(requireRole(req, 'viewer')).rejects.toMatchObject({
|
|
||||||
statusCode: 403,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refresh token cannot be used for authenticated requests', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'lysnrai' });
|
|
||||||
const refreshToken = await jwt.createRefreshToken({
|
|
||||||
sub: 'user-001',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
});
|
|
||||||
|
|
||||||
const req = { headers: { authorization: `Bearer ${refreshToken}` } };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Invalid or expired token',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => {
|
|
||||||
// extractAuth only checks type=access via jwtVerify without issuer
|
|
||||||
// But verifyToken checks issuer — this is the cross-service security model
|
|
||||||
const jwtA = createJwtUtils({ issuer: 'lysnrai' });
|
|
||||||
const jwtB = createJwtUtils({ issuer: 'mindlyst' });
|
|
||||||
|
|
||||||
const tokenA = await jwtA.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
// verifyToken with wrong issuer rejects
|
|
||||||
const resultB = await jwtB.verifyToken(tokenA);
|
|
||||||
expect(resultB).toBeNull();
|
|
||||||
|
|
||||||
// verifyToken with correct issuer passes
|
|
||||||
const resultA = await jwtA.verifyToken(tokenA);
|
|
||||||
expect(resultA).not.toBeNull();
|
|
||||||
expect(resultA!.sub).toBe('u1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong password fails login flow before token issuance', async () => {
|
|
||||||
const storedHash = await hashPassword('CorrectPassword');
|
|
||||||
const passwordValid = await verifyPassword('WrongPassword', storedHash);
|
|
||||||
expect(passwordValid).toBe(false);
|
|
||||||
// No token should be issued — the flow stops here
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { createJwtUtils, extractAuth, requireRole } from '../index.js';
|
|
||||||
|
|
||||||
const SECRET = 'test-jwt-secret-at-least-32-chars-long!!';
|
|
||||||
let validAccessToken: string;
|
|
||||||
let refreshToken: string;
|
|
||||||
|
|
||||||
describe('extractAuth', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env.JWT_SECRET = SECRET;
|
|
||||||
const jwt = createJwtUtils({ issuer: 'test-issuer' });
|
|
||||||
validAccessToken = await jwt.createAccessToken({
|
|
||||||
sub: 'user-1',
|
|
||||||
email: 'test@example.com',
|
|
||||||
role: 'admin',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
});
|
|
||||||
refreshToken = await jwt.createRefreshToken({ sub: 'user-1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts auth from valid Bearer token', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
|
|
||||||
const payload = await extractAuth(req);
|
|
||||||
expect(payload.sub).toBe('user-1');
|
|
||||||
expect(payload.email).toBe('test@example.com');
|
|
||||||
expect(payload.role).toBe('admin');
|
|
||||||
expect(payload.productId).toBe('lysnrai');
|
|
||||||
expect(payload.type).toBe('access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 when no authorization header', async () => {
|
|
||||||
const req = { headers: {} };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Unauthorized',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 when authorization header is not Bearer', async () => {
|
|
||||||
const req = { headers: { authorization: 'Basic abc123' } };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Unauthorized',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 for invalid token', async () => {
|
|
||||||
const req = { headers: { authorization: 'Bearer garbage.not.valid' } };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Invalid or expired token',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 for refresh token (requires access type)', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${refreshToken}` } };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
message: 'Invalid or expired token',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 for empty Bearer value', async () => {
|
|
||||||
const req = { headers: { authorization: 'Bearer ' } };
|
|
||||||
await expect(extractAuth(req)).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('requireRole', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.JWT_SECRET = SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes when role matches', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
|
|
||||||
const payload = await requireRole(req, 'admin');
|
|
||||||
expect(payload.sub).toBe('user-1');
|
|
||||||
expect(payload.role).toBe('admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes when role is in allowed list', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
|
|
||||||
const payload = await requireRole(req, 'viewer', 'admin', 'super_admin');
|
|
||||||
expect(payload.role).toBe('admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 403 when role does not match', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
|
|
||||||
await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Insufficient permissions',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes with no roles specified (any authenticated user)', async () => {
|
|
||||||
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
|
|
||||||
const payload = await requireRole(req);
|
|
||||||
expect(payload.sub).toBe('user-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 401 when no auth header (before checking role)', async () => {
|
|
||||||
const req = { headers: {} };
|
|
||||||
await expect(requireRole(req, 'admin')).rejects.toMatchObject({
|
|
||||||
statusCode: 401,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
133
vendor/bytelyst/auth/src/__tests__/rs256.test.ts
vendored
133
vendor/bytelyst/auth/src/__tests__/rs256.test.ts
vendored
@ -1,133 +0,0 @@
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { generateKeyPair } from 'jose';
|
|
||||||
import { createJwtUtils } from '../index.js';
|
|
||||||
|
|
||||||
describe('JWT RS256 support (Phase 4C)', () => {
|
|
||||||
const SECRET = 'test-jwt-secret-at-least-32-chars-long!!';
|
|
||||||
let rsaPrivateKey: string;
|
|
||||||
let rsaPublicKey: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
process.env.JWT_SECRET = SECRET;
|
|
||||||
|
|
||||||
// Generate RSA key pair for testing (extractable required for PEM export in jose v6)
|
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true });
|
|
||||||
const { exportPKCS8, exportSPKI } = await import('jose');
|
|
||||||
rsaPrivateKey = await exportPKCS8(privateKey);
|
|
||||||
rsaPublicKey = await exportSPKI(publicKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
delete process.env.JWT_SECRET;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('signs and verifies tokens with RS256', async () => {
|
|
||||||
const jwt = createJwtUtils({
|
|
||||||
issuer: 'test-rs256',
|
|
||||||
algorithm: 'RS256',
|
|
||||||
rsaPrivateKey,
|
|
||||||
rsaPublicKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await jwt.createAccessToken({
|
|
||||||
sub: 'user-1',
|
|
||||||
email: 'rs256@test.com',
|
|
||||||
role: 'admin',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(typeof token).toBe('string');
|
|
||||||
expect(token.split('.')).toHaveLength(3);
|
|
||||||
|
|
||||||
const payload = await jwt.verifyToken(token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('user-1');
|
|
||||||
expect(payload!.email).toBe('rs256@test.com');
|
|
||||||
expect(payload!.type).toBe('access');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dual verify: RS256 verifier can fall back to HS256 tokens', async () => {
|
|
||||||
// Create an HS256 token (simulates old tokens during migration)
|
|
||||||
const hs256Jwt = createJwtUtils({ issuer: 'dual-test' });
|
|
||||||
const hs256Token = await hs256Jwt.createAccessToken({
|
|
||||||
sub: 'u-old',
|
|
||||||
email: 'old@test.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify with RS256-configured jwt (should fall back to HS256)
|
|
||||||
const dualJwt = createJwtUtils({
|
|
||||||
issuer: 'dual-test',
|
|
||||||
algorithm: 'RS256',
|
|
||||||
rsaPrivateKey,
|
|
||||||
rsaPublicKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await dualJwt.verifyToken(hs256Token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('u-old');
|
|
||||||
expect(payload!.email).toBe('old@test.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('RS256 token is NOT verified by HS256-only verifier with different issuer', async () => {
|
|
||||||
const rs256Jwt = createJwtUtils({
|
|
||||||
issuer: 'rs256-only',
|
|
||||||
algorithm: 'RS256',
|
|
||||||
rsaPrivateKey,
|
|
||||||
rsaPublicKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await rs256Jwt.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'a@b.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
// HS256-only verifier with different issuer should reject
|
|
||||||
const hs256Jwt = createJwtUtils({ issuer: 'different-issuer' });
|
|
||||||
const result = await hs256Jwt.verifyToken(token);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('RS256 refresh token works', async () => {
|
|
||||||
const jwt = createJwtUtils({
|
|
||||||
issuer: 'test-rs256',
|
|
||||||
algorithm: 'RS256',
|
|
||||||
rsaPrivateKey,
|
|
||||||
rsaPublicKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = await jwt.createRefreshToken({ sub: 'user-1' });
|
|
||||||
|
|
||||||
const payload = await jwt.verifyToken(token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('user-1');
|
|
||||||
expect(payload!.type).toBe('refresh');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when RS256 signing without private key', async () => {
|
|
||||||
const jwt = createJwtUtils({
|
|
||||||
issuer: 'test-no-key',
|
|
||||||
algorithm: 'RS256',
|
|
||||||
rsaPublicKey, // public only, no private
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
jwt.createAccessToken({ sub: 'u1', email: 'a@b.com', role: 'user' })
|
|
||||||
).rejects.toThrow('rsaPrivateKey is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('HS256 still works as default (backward compatible)', async () => {
|
|
||||||
const jwt = createJwtUtils({ issuer: 'hs256-compat' });
|
|
||||||
|
|
||||||
const token = await jwt.createAccessToken({
|
|
||||||
sub: 'u1',
|
|
||||||
email: 'compat@test.com',
|
|
||||||
role: 'user',
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await jwt.verifyToken(token);
|
|
||||||
expect(payload).not.toBeNull();
|
|
||||||
expect(payload!.sub).toBe('u1');
|
|
||||||
expect(payload!.email).toBe('compat@test.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
5
vendor/bytelyst/auth/src/index.ts
vendored
5
vendor/bytelyst/auth/src/index.ts
vendored
@ -1,5 +0,0 @@
|
|||||||
export { createJwtUtils } from './jwt.js';
|
|
||||||
export { extractAuth, requireRole } from './middleware.js';
|
|
||||||
export { hashPassword, verifyPassword } from './password.js';
|
|
||||||
export { getCurrentUser } from './server-auth.js';
|
|
||||||
export type { TokenPayload, AuthPayload, JwtUtilsOptions, JwtUtils } from './types.js';
|
|
||||||
176
vendor/bytelyst/auth/src/jwt.ts
vendored
176
vendor/bytelyst/auth/src/jwt.ts
vendored
@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* JWT utilities — configurable issuer, expiry, and algorithm.
|
|
||||||
* Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose.
|
|
||||||
*
|
|
||||||
* RS256 mode (Phase 4C SmartAuth):
|
|
||||||
* - Sign with RSA private key (PEM)
|
|
||||||
* - Verify with RSA public key (PEM) or remote JWKS URL
|
|
||||||
* - Dual verification: tries RS256 first, falls back to HS256 during migration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
SignJWT,
|
|
||||||
jwtVerify,
|
|
||||||
importPKCS8,
|
|
||||||
importSPKI,
|
|
||||||
createRemoteJWKSet,
|
|
||||||
type CryptoKey as JoseCryptoKey,
|
|
||||||
} from 'jose';
|
|
||||||
import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js';
|
|
||||||
|
|
||||||
function getHmacSecret(): Uint8Array {
|
|
||||||
const secret = process.env.JWT_SECRET;
|
|
||||||
if (!secret) throw new Error('JWT_SECRET must be set');
|
|
||||||
return new TextEncoder().encode(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a JWT utility set with the given issuer and expiry configuration.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* // HS256 (default, backward-compatible)
|
|
||||||
* const jwt = createJwtUtils({ issuer: "bytelyst-platform" });
|
|
||||||
*
|
|
||||||
* // RS256 (SmartAuth Phase 4C)
|
|
||||||
* const jwt = createJwtUtils({
|
|
||||||
* issuer: "bytelyst-platform",
|
|
||||||
* algorithm: "RS256",
|
|
||||||
* rsaPrivateKey: process.env.JWT_PRIVATE_KEY,
|
|
||||||
* rsaPublicKey: process.env.JWT_PUBLIC_KEY,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // RS256 verify-only (product backends — no private key)
|
|
||||||
* const jwt = createJwtUtils({
|
|
||||||
* issuer: "bytelyst-platform",
|
|
||||||
* algorithm: "RS256",
|
|
||||||
* jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json",
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createJwtUtils(options: JwtUtilsOptions): JwtUtils {
|
|
||||||
const {
|
|
||||||
issuer,
|
|
||||||
accessTokenExpiry = '1h',
|
|
||||||
refreshTokenExpiry = '30d',
|
|
||||||
algorithm = 'HS256',
|
|
||||||
rsaPrivateKey,
|
|
||||||
rsaPublicKey,
|
|
||||||
jwksUrl,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// ── Key caches ────────────────────────────────────
|
|
||||||
|
|
||||||
let _rsaPrivateKeyObj: JoseCryptoKey | null = null;
|
|
||||||
let _rsaPublicKeyObj: JoseCryptoKey | null = null;
|
|
||||||
let _jwksKeySet: ReturnType<typeof createRemoteJWKSet> | null = null;
|
|
||||||
|
|
||||||
async function getRsaPrivateKey(): Promise<JoseCryptoKey> {
|
|
||||||
if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj;
|
|
||||||
if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing');
|
|
||||||
_rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey;
|
|
||||||
return _rsaPrivateKeyObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRsaPublicKey(): Promise<JoseCryptoKey> {
|
|
||||||
if (_rsaPublicKeyObj) return _rsaPublicKeyObj;
|
|
||||||
if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification');
|
|
||||||
_rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey;
|
|
||||||
return _rsaPublicKeyObj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJwksKeySet(): ReturnType<typeof createRemoteJWKSet> {
|
|
||||||
if (_jwksKeySet) return _jwksKeySet;
|
|
||||||
if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification');
|
|
||||||
_jwksKeySet = createRemoteJWKSet(new URL(jwksUrl));
|
|
||||||
return _jwksKeySet;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Signing ───────────────────────────────────────
|
|
||||||
|
|
||||||
async function sign(claims: Record<string, unknown>, expiry: string): Promise<string> {
|
|
||||||
if (algorithm === 'RS256') {
|
|
||||||
const key = await getRsaPrivateKey();
|
|
||||||
return new SignJWT(claims)
|
|
||||||
.setProtectedHeader({ alg: 'RS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(expiry)
|
|
||||||
.setIssuer(issuer)
|
|
||||||
.sign(key);
|
|
||||||
}
|
|
||||||
return new SignJWT(claims)
|
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(expiry)
|
|
||||||
.setIssuer(issuer)
|
|
||||||
.sign(getHmacSecret());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Verification (dual: RS256 first, HS256 fallback) ──
|
|
||||||
|
|
||||||
async function verifyWithRS256(token: string): Promise<TokenPayload | null> {
|
|
||||||
try {
|
|
||||||
if (jwksUrl) {
|
|
||||||
const keySet = getJwksKeySet();
|
|
||||||
const { payload } = await jwtVerify(token, keySet, { issuer });
|
|
||||||
return payload as unknown as TokenPayload;
|
|
||||||
}
|
|
||||||
if (rsaPublicKey) {
|
|
||||||
const key = await getRsaPublicKey();
|
|
||||||
const { payload } = await jwtVerify(token, key, { issuer });
|
|
||||||
return payload as unknown as TokenPayload;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyWithHS256(token: string): Promise<TokenPayload | null> {
|
|
||||||
try {
|
|
||||||
const secret = getHmacSecret();
|
|
||||||
const { payload } = await jwtVerify(token, secret, { issuer });
|
|
||||||
return payload as unknown as TokenPayload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async createAccessToken(payload) {
|
|
||||||
return sign(
|
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
productId: payload.productId || issuer,
|
|
||||||
type: 'access',
|
|
||||||
},
|
|
||||||
accessTokenExpiry
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async createRefreshToken(payload) {
|
|
||||||
return sign(
|
|
||||||
{
|
|
||||||
sub: payload.sub,
|
|
||||||
productId: payload.productId || issuer,
|
|
||||||
type: 'refresh',
|
|
||||||
},
|
|
||||||
refreshTokenExpiry
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async verifyToken(token: string) {
|
|
||||||
// Dual verification: try RS256 first (if configured), then HS256 fallback
|
|
||||||
if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) {
|
|
||||||
const rs256Result = await verifyWithRS256(token);
|
|
||||||
if (rs256Result) return rs256Result;
|
|
||||||
}
|
|
||||||
// HS256 fallback (safe during migration; removed after full RS256 rollout)
|
|
||||||
try {
|
|
||||||
return await verifyWithHS256(token);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
54
vendor/bytelyst/auth/src/middleware.ts
vendored
54
vendor/bytelyst/auth/src/middleware.ts
vendored
@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Fastify auth middleware — validates JWT tokens from Authorization headers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { jwtVerify } from 'jose';
|
|
||||||
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
|
|
||||||
import type { AuthPayload } from './types.js';
|
|
||||||
|
|
||||||
function getSecret(): Uint8Array {
|
|
||||||
const secret = process.env.JWT_SECRET;
|
|
||||||
if (!secret) throw new Error('JWT_SECRET must be set');
|
|
||||||
return new TextEncoder().encode(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract and verify auth payload from an Authorization header.
|
|
||||||
* Works with any request-like object that has headers.authorization.
|
|
||||||
*
|
|
||||||
* @throws Error with message "Unauthorized" if no valid Bearer token
|
|
||||||
* @throws Error with message "Invalid or expired token" if verification fails
|
|
||||||
*/
|
|
||||||
export async function extractAuth(req: {
|
|
||||||
headers: { authorization?: string };
|
|
||||||
}): Promise<AuthPayload> {
|
|
||||||
const auth = req.headers.authorization;
|
|
||||||
if (!auth?.startsWith('Bearer ')) {
|
|
||||||
throw new UnauthorizedError();
|
|
||||||
}
|
|
||||||
const token = auth.slice(7);
|
|
||||||
try {
|
|
||||||
const { payload } = await jwtVerify(token, getSecret());
|
|
||||||
const p = payload as unknown as AuthPayload;
|
|
||||||
if (p.type !== 'access') throw new Error('Not an access token');
|
|
||||||
return p;
|
|
||||||
} catch {
|
|
||||||
throw new UnauthorizedError('Invalid or expired token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require specific roles. Extracts auth first, then checks role.
|
|
||||||
*
|
|
||||||
* @throws Error with statusCode 403 if role doesn't match
|
|
||||||
*/
|
|
||||||
export async function requireRole(
|
|
||||||
req: { headers: { authorization?: string } },
|
|
||||||
...roles: string[]
|
|
||||||
): Promise<AuthPayload> {
|
|
||||||
const payload = await extractAuth(req);
|
|
||||||
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
|
|
||||||
throw new ForbiddenError('Insufficient permissions');
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
15
vendor/bytelyst/auth/src/password.ts
vendored
15
vendor/bytelyst/auth/src/password.ts
vendored
@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* Password hashing utilities using bcryptjs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
|
|
||||||
const SALT_ROUNDS = 12;
|
|
||||||
|
|
||||||
export async function hashPassword(plain: string): Promise<string> {
|
|
||||||
return bcrypt.hash(plain, SALT_ROUNDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
|
||||||
return bcrypt.compare(plain, hash);
|
|
||||||
}
|
|
||||||
26
vendor/bytelyst/auth/src/server-auth.ts
vendored
26
vendor/bytelyst/auth/src/server-auth.ts
vendored
@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Server-side auth helpers for Next.js API routes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TokenPayload } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current user from an Authorization header value.
|
|
||||||
* Pairs with a verifyToken function and a getUserById function.
|
|
||||||
*
|
|
||||||
* @param authHeader - The Authorization header value (e.g., "Bearer xxx")
|
|
||||||
* @param verifyToken - Function to verify the JWT and return a payload
|
|
||||||
* @param getUserById - Function to look up the user by their ID
|
|
||||||
* @returns The user object or null if auth fails
|
|
||||||
*/
|
|
||||||
export async function getCurrentUser<TUser>(
|
|
||||||
authHeader: string | null,
|
|
||||||
verifyToken: (token: string) => Promise<TokenPayload | null>,
|
|
||||||
getUserById: (id: string) => Promise<TUser | null>
|
|
||||||
): Promise<TUser | null> {
|
|
||||||
if (!authHeader?.startsWith('Bearer ')) return null;
|
|
||||||
const token = authHeader.slice(7);
|
|
||||||
const payload = await verifyToken(token);
|
|
||||||
if (!payload || payload.type !== 'access') return null;
|
|
||||||
return getUserById(payload.sub);
|
|
||||||
}
|
|
||||||
41
vendor/bytelyst/auth/src/types.ts
vendored
41
vendor/bytelyst/auth/src/types.ts
vendored
@ -1,41 +0,0 @@
|
|||||||
export interface TokenPayload {
|
|
||||||
sub: string;
|
|
||||||
email?: string;
|
|
||||||
role?: string;
|
|
||||||
productId?: string;
|
|
||||||
type?: 'access' | 'refresh';
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthPayload {
|
|
||||||
sub: string;
|
|
||||||
email?: string;
|
|
||||||
role?: string;
|
|
||||||
productId?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JwtUtilsOptions {
|
|
||||||
issuer: string;
|
|
||||||
accessTokenExpiry?: string;
|
|
||||||
refreshTokenExpiry?: string;
|
|
||||||
/** JWT signing algorithm. Default: 'HS256'. Set to 'RS256' for asymmetric. */
|
|
||||||
algorithm?: 'HS256' | 'RS256';
|
|
||||||
/** RSA private key (PEM) for RS256 signing. Required when algorithm is 'RS256'. */
|
|
||||||
rsaPrivateKey?: string;
|
|
||||||
/** RSA public key (PEM) for RS256 verification. Used when algorithm is 'RS256'. */
|
|
||||||
rsaPublicKey?: string;
|
|
||||||
/** Remote JWKS URL for RS256 verification (e.g. platform-service /.well-known/jwks.json). */
|
|
||||||
jwksUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JwtUtils {
|
|
||||||
createAccessToken(payload: {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
productId?: string;
|
|
||||||
}): Promise<string>;
|
|
||||||
createRefreshToken(payload: { sub: string; productId?: string }): Promise<string>;
|
|
||||||
verifyToken(token: string): Promise<TokenPayload | null>;
|
|
||||||
}
|
|
||||||
9
vendor/bytelyst/auth/tsconfig.json
vendored
9
vendor/bytelyst/auth/tsconfig.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
8
vendor/bytelyst/auth/vitest.config.ts
vendored
8
vendor/bytelyst/auth/vitest.config.ts
vendored
@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
pool: 'forks',
|
|
||||||
testTimeout: 15_000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
33
vendor/bytelyst/backend-config/package.json
vendored
33
vendor/bytelyst/backend-config/package.json
vendored
@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/backend-config",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"description": "Shared Zod config schema base for Fastify product backends",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --pool forks",
|
|
||||||
"clean": "rm -rf dist"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"zod": "^3.24.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vitest": "^3.0.5"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
82
vendor/bytelyst/backend-config/src/index.test.ts
vendored
82
vendor/bytelyst/backend-config/src/index.test.ts
vendored
@ -1,82 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { baseBackendConfigSchema, parseBackendConfig } from './index.js';
|
|
||||||
|
|
||||||
describe('baseBackendConfigSchema', () => {
|
|
||||||
it('parses minimal valid env', () => {
|
|
||||||
const config = baseBackendConfigSchema.parse({
|
|
||||||
JWT_SECRET: 'test-secret',
|
|
||||||
});
|
|
||||||
expect(config.PORT).toBe(3000);
|
|
||||||
expect(config.HOST).toBe('0.0.0.0');
|
|
||||||
expect(config.NODE_ENV).toBe('development');
|
|
||||||
expect(config.DB_PROVIDER).toBe('cosmos');
|
|
||||||
expect(config.COSMOS_DATABASE).toBe('lysnrai');
|
|
||||||
expect(config.JWT_SECRET).toBe('test-secret');
|
|
||||||
expect(config.PLATFORM_JWKS_URL).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies overrides', () => {
|
|
||||||
const config = baseBackendConfigSchema.parse({
|
|
||||||
PORT: '4010',
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
DB_PROVIDER: 'memory',
|
|
||||||
JWT_SECRET: 'prod-secret',
|
|
||||||
PLATFORM_JWKS_URL: 'https://example.com/.well-known/jwks.json',
|
|
||||||
});
|
|
||||||
expect(config.PORT).toBe(4010);
|
|
||||||
expect(config.NODE_ENV).toBe('production');
|
|
||||||
expect(config.DB_PROVIDER).toBe('memory');
|
|
||||||
expect(config.PLATFORM_JWKS_URL).toBe('https://example.com/.well-known/jwks.json');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing JWT_SECRET', () => {
|
|
||||||
expect(() => baseBackendConfigSchema.parse({})).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid NODE_ENV', () => {
|
|
||||||
expect(() => baseBackendConfigSchema.parse({ JWT_SECRET: 's', NODE_ENV: 'staging' })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid DB_PROVIDER', () => {
|
|
||||||
expect(() =>
|
|
||||||
baseBackendConfigSchema.parse({ JWT_SECRET: 's', DB_PROVIDER: 'postgres' })
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('baseBackendConfigSchema.extend()', () => {
|
|
||||||
const extendedSchema = baseBackendConfigSchema.extend({
|
|
||||||
PLATFORM_SERVICE_URL: baseBackendConfigSchema.shape.HOST.default('http://localhost:4003'),
|
|
||||||
CUSTOM_FLAG: baseBackendConfigSchema.shape.NODE_ENV.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses extended config with product-specific fields', () => {
|
|
||||||
const config = extendedSchema.parse({
|
|
||||||
JWT_SECRET: 'test-secret',
|
|
||||||
PORT: '4018',
|
|
||||||
SERVICE_NAME: 'actiontrail-backend',
|
|
||||||
});
|
|
||||||
expect(config.PORT).toBe(4018);
|
|
||||||
expect(config.SERVICE_NAME).toBe('actiontrail-backend');
|
|
||||||
expect(config.PLATFORM_SERVICE_URL).toBe('http://localhost:4003');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseBackendConfig', () => {
|
|
||||||
it('parses from explicit env object', () => {
|
|
||||||
const config = parseBackendConfig(baseBackendConfigSchema, {
|
|
||||||
JWT_SECRET: 'from-env',
|
|
||||||
PORT: '9999',
|
|
||||||
});
|
|
||||||
expect(config.JWT_SECRET).toBe('from-env');
|
|
||||||
expect(config.PORT).toBe(9999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with extended schemas', () => {
|
|
||||||
const schema = baseBackendConfigSchema.extend({
|
|
||||||
WEBHOOK_SECRET: baseBackendConfigSchema.shape.HOST.default('dev-webhook'),
|
|
||||||
});
|
|
||||||
const config = parseBackendConfig(schema, { JWT_SECRET: 'x' });
|
|
||||||
expect(config.WEBHOOK_SECRET).toBe('dev-webhook');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
39
vendor/bytelyst/backend-config/src/index.ts
vendored
39
vendor/bytelyst/backend-config/src/index.ts
vendored
@ -1,39 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base Zod schema shared by all product backends.
|
|
||||||
*
|
|
||||||
* Products extend this with `.extend({...})` to add product-specific fields.
|
|
||||||
* The base covers: server, CORS, Cosmos DB, JWT auth, DB provider.
|
|
||||||
*/
|
|
||||||
export const baseBackendConfigSchema = z.object({
|
|
||||||
PORT: z.coerce.number().default(3000),
|
|
||||||
HOST: z.string().default('0.0.0.0'),
|
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
||||||
CORS_ORIGIN: z.string().optional(),
|
|
||||||
SERVICE_NAME: z.string().default('backend'),
|
|
||||||
|
|
||||||
DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'),
|
|
||||||
COSMOS_ENDPOINT: z.string().default(''),
|
|
||||||
COSMOS_KEY: z.string().default(''),
|
|
||||||
COSMOS_DATABASE: z.string().default('lysnrai'),
|
|
||||||
|
|
||||||
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
|
||||||
PLATFORM_JWKS_URL: z.string().url().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type BaseBackendConfig = z.infer<typeof baseBackendConfigSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and validate backend config from process.env.
|
|
||||||
*
|
|
||||||
* @param schema — Zod object schema (typically `baseBackendConfigSchema.extend({...})`)
|
|
||||||
* @param env — environment object (defaults to process.env)
|
|
||||||
* @returns Validated, typed config
|
|
||||||
*/
|
|
||||||
export function parseBackendConfig<T extends z.ZodRawShape>(
|
|
||||||
schema: z.ZodObject<T>,
|
|
||||||
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
|
|
||||||
): z.infer<z.ZodObject<T>> {
|
|
||||||
return schema.parse(env);
|
|
||||||
}
|
|
||||||
9
vendor/bytelyst/backend-config/tsconfig.json
vendored
9
vendor/bytelyst/backend-config/tsconfig.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
30
vendor/bytelyst/backend-flags/package.json
vendored
30
vendor/bytelyst/backend-flags/package.json
vendored
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/backend-flags",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"description": "In-memory feature flag registry for Fastify product backends",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --pool forks",
|
|
||||||
"clean": "rm -rf dist"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vitest": "^3.0.5"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
43
vendor/bytelyst/backend-flags/src/index.test.ts
vendored
43
vendor/bytelyst/backend-flags/src/index.test.ts
vendored
@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { createFlagRegistry } from './index.js';
|
|
||||||
|
|
||||||
describe('createFlagRegistry', () => {
|
|
||||||
it('returns default flag values', () => {
|
|
||||||
const registry = createFlagRegistry({
|
|
||||||
defaults: { 'feature.a': true, 'feature.b': false },
|
|
||||||
});
|
|
||||||
expect(registry.isFeatureEnabled('feature.a')).toBe(true);
|
|
||||||
expect(registry.isFeatureEnabled('feature.b')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for unknown flags', () => {
|
|
||||||
const registry = createFlagRegistry({ defaults: {} });
|
|
||||||
expect(registry.isFeatureEnabled('nonexistent')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getAllFlags returns all defaults', () => {
|
|
||||||
const registry = createFlagRegistry({
|
|
||||||
defaults: { a: true, b: false, c: true },
|
|
||||||
});
|
|
||||||
expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setFlag overrides a value', () => {
|
|
||||||
const registry = createFlagRegistry({ defaults: { x: false } });
|
|
||||||
expect(registry.isFeatureEnabled('x')).toBe(false);
|
|
||||||
registry.setFlag('x', true);
|
|
||||||
expect(registry.isFeatureEnabled('x')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setFlag creates new flags', () => {
|
|
||||||
const registry = createFlagRegistry({ defaults: {} });
|
|
||||||
registry.setFlag('new.flag', true);
|
|
||||||
expect(registry.isFeatureEnabled('new.flag')).toBe(true);
|
|
||||||
expect(registry.getAllFlags()).toEqual({ 'new.flag': true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts userId parameter without error', () => {
|
|
||||||
const registry = createFlagRegistry({ defaults: { a: true } });
|
|
||||||
expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
38
vendor/bytelyst/backend-flags/src/index.ts
vendored
38
vendor/bytelyst/backend-flags/src/index.ts
vendored
@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory feature flag registry for product backends.
|
|
||||||
*
|
|
||||||
* Products call createFlagRegistry() with their default flags,
|
|
||||||
* then use isFeatureEnabled/getAllFlags/setFlag as needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FlagRegistry {
|
|
||||||
isFeatureEnabled(flag: string, userId?: string): boolean;
|
|
||||||
getAllFlags(): Record<string, boolean>;
|
|
||||||
setFlag(flag: string, value: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlagRegistryOptions {
|
|
||||||
/** Default flag values. */
|
|
||||||
defaults: Record<string, boolean>;
|
|
||||||
/** Master switch — when false, flags are still resolved from defaults but
|
|
||||||
* the registry won't attempt remote/dynamic flag resolution (future use). */
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry {
|
|
||||||
const flags: Map<string, boolean> = new Map(Object.entries(opts.defaults));
|
|
||||||
|
|
||||||
return {
|
|
||||||
isFeatureEnabled(flag: string, _userId?: string): boolean {
|
|
||||||
return flags.get(flag) ?? false;
|
|
||||||
},
|
|
||||||
|
|
||||||
getAllFlags(): Record<string, boolean> {
|
|
||||||
return Object.fromEntries(flags);
|
|
||||||
},
|
|
||||||
|
|
||||||
setFlag(flag: string, value: boolean): void {
|
|
||||||
flags.set(flag, value);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
9
vendor/bytelyst/backend-flags/tsconfig.json
vendored
9
vendor/bytelyst/backend-flags/tsconfig.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
30
vendor/bytelyst/backend-telemetry/package.json
vendored
30
vendor/bytelyst/backend-telemetry/package.json
vendored
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/backend-telemetry",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"description": "In-memory telemetry event buffer for Fastify product backends",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --pool forks",
|
|
||||||
"clean": "rm -rf dist"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vitest": "^3.0.5"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { createTelemetryBuffer } from './index.js';
|
|
||||||
|
|
||||||
describe('createTelemetryBuffer', () => {
|
|
||||||
it('buffers events when enabled', () => {
|
|
||||||
const buf = createTelemetryBuffer({ enabled: true });
|
|
||||||
buf.trackEvent('test.event', 'user-1', { key: 'val' });
|
|
||||||
const events = buf.getBufferedEvents();
|
|
||||||
expect(events).toHaveLength(1);
|
|
||||||
expect(events[0].event).toBe('test.event');
|
|
||||||
expect(events[0].userId).toBe('user-1');
|
|
||||||
expect(events[0].properties).toEqual({ key: 'val' });
|
|
||||||
expect(events[0].timestamp).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is a no-op when disabled', () => {
|
|
||||||
const buf = createTelemetryBuffer({ enabled: false });
|
|
||||||
buf.trackEvent('test.event', 'user-1');
|
|
||||||
expect(buf.getBufferedEvents()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flushEvents returns and clears buffer', () => {
|
|
||||||
const buf = createTelemetryBuffer({ enabled: true });
|
|
||||||
buf.trackEvent('a');
|
|
||||||
buf.trackEvent('b');
|
|
||||||
const flushed = buf.flushEvents();
|
|
||||||
expect(flushed).toHaveLength(2);
|
|
||||||
expect(buf.getBufferedEvents()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getBufferedEvents returns a copy', () => {
|
|
||||||
const buf = createTelemetryBuffer({ enabled: true });
|
|
||||||
buf.trackEvent('a');
|
|
||||||
const copy = buf.getBufferedEvents();
|
|
||||||
copy.push({ event: 'fake' });
|
|
||||||
expect(buf.getBufferedEvents()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing optional fields', () => {
|
|
||||||
const buf = createTelemetryBuffer({ enabled: true });
|
|
||||||
buf.trackEvent('minimal');
|
|
||||||
const events = buf.getBufferedEvents();
|
|
||||||
expect(events[0].userId).toBeUndefined();
|
|
||||||
expect(events[0].properties).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
50
vendor/bytelyst/backend-telemetry/src/index.ts
vendored
50
vendor/bytelyst/backend-telemetry/src/index.ts
vendored
@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory telemetry event buffer for product backends.
|
|
||||||
*
|
|
||||||
* Products call createTelemetryBuffer() with an enabled flag,
|
|
||||||
* then use trackEvent/getBufferedEvents/flushEvents as needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TelemetryEvent {
|
|
||||||
event: string;
|
|
||||||
userId?: string;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TelemetryBuffer {
|
|
||||||
trackEvent(event: string, userId?: string, properties?: Record<string, unknown>): void;
|
|
||||||
getBufferedEvents(): TelemetryEvent[];
|
|
||||||
flushEvents(): TelemetryEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TelemetryBufferOptions {
|
|
||||||
/** Master switch — when false, trackEvent is a no-op. */
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer {
|
|
||||||
const buffer: TelemetryEvent[] = [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
trackEvent(event: string, userId?: string, properties?: Record<string, unknown>): void {
|
|
||||||
if (!opts.enabled) return;
|
|
||||||
buffer.push({
|
|
||||||
event,
|
|
||||||
userId,
|
|
||||||
properties,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getBufferedEvents(): TelemetryEvent[] {
|
|
||||||
return [...buffer];
|
|
||||||
},
|
|
||||||
|
|
||||||
flushEvents(): TelemetryEvent[] {
|
|
||||||
const flushed = [...buffer];
|
|
||||||
buffer.length = 0;
|
|
||||||
return flushed;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
1
vendor/bytelyst/billing-client/.gitignore
vendored
1
vendor/bytelyst/billing-client/.gitignore
vendored
@ -1 +0,0 @@
|
|||||||
*.tgz
|
|
||||||
28
vendor/bytelyst/billing-client/package.json
vendored
28
vendor/bytelyst/billing-client/package.json
vendored
@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/billing-client",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3",
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
vendor/bytelyst/billing-client/src/index.test.ts
vendored
119
vendor/bytelyst/billing-client/src/index.test.ts
vendored
@ -1,119 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createBillingClient, BillingApiError } from './index.js';
|
|
||||||
import type { BillingClient } from './index.js';
|
|
||||||
|
|
||||||
function mockFetch(status: number, body: unknown) {
|
|
||||||
return vi.fn().mockResolvedValue({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createBillingClient', () => {
|
|
||||||
let client: BillingClient;
|
|
||||||
const config = {
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'notelett',
|
|
||||||
getAccessToken: () => 'test-token',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
client = createBillingClient(config);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('listPlans — returns plans array', async () => {
|
|
||||||
const plans = [{ name: 'free', displayName: 'Free', price: 0 }];
|
|
||||||
globalThis.fetch = mockFetch(200, { plans });
|
|
||||||
const result = await client.listPlans();
|
|
||||||
expect(result).toEqual(plans);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:4003/api/plans',
|
|
||||||
expect.objectContaining({ method: 'GET' })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getPlan — returns single plan', async () => {
|
|
||||||
const plan = { name: 'pro', displayName: 'Pro', price: 9.99 };
|
|
||||||
globalThis.fetch = mockFetch(200, plan);
|
|
||||||
const result = await client.getPlan('pro');
|
|
||||||
expect(result).toEqual(plan);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getSubscription — returns subscription', async () => {
|
|
||||||
const sub = { id: 'sub_1', plan: 'free', status: 'active' };
|
|
||||||
globalThis.fetch = mockFetch(200, sub);
|
|
||||||
const result = await client.getSubscription();
|
|
||||||
expect(result).toEqual(sub);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getSubscription — returns null on 404', async () => {
|
|
||||||
globalThis.fetch = mockFetch(404, { message: 'Not found' });
|
|
||||||
const result = await client.getSubscription();
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changePlan — sends plan in body', async () => {
|
|
||||||
const sub = { id: 'sub_1', plan: 'pro', status: 'active' };
|
|
||||||
globalThis.fetch = mockFetch(200, sub);
|
|
||||||
const result = await client.changePlan('pro');
|
|
||||||
expect(result.plan).toBe('pro');
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:4003/api/subscriptions/me/change-plan',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ plan: 'pro' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancelSubscription — POST to cancel endpoint', async () => {
|
|
||||||
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true };
|
|
||||||
globalThis.fetch = mockFetch(200, sub);
|
|
||||||
const result = await client.cancelSubscription();
|
|
||||||
expect(result.cancelAtPeriodEnd).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resumeSubscription — POST to resume endpoint', async () => {
|
|
||||||
const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false };
|
|
||||||
globalThis.fetch = mockFetch(200, sub);
|
|
||||||
const result = await client.resumeSubscription();
|
|
||||||
expect(result.cancelAtPeriodEnd).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('listPayments — returns payments array', async () => {
|
|
||||||
const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }];
|
|
||||||
globalThis.fetch = mockFetch(200, { payments });
|
|
||||||
const result = await client.listPayments();
|
|
||||||
expect(result).toEqual(payments);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getUsage — returns usage summary', async () => {
|
|
||||||
const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 };
|
|
||||||
globalThis.fetch = mockFetch(200, usage);
|
|
||||||
const result = await client.getUsage();
|
|
||||||
expect(result.tokensRemaining).toBe(9500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends auth header and product-id header', async () => {
|
|
||||||
globalThis.fetch = mockFetch(200, { plans: [] });
|
|
||||||
await client.listPlans();
|
|
||||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
const headers = call[1].headers;
|
|
||||||
expect(headers['Authorization']).toBe('Bearer test-token');
|
|
||||||
expect(headers['x-product-id']).toBe('notelett');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws BillingApiError on non-ok response', async () => {
|
|
||||||
globalThis.fetch = mockFetch(403, { message: 'Forbidden' });
|
|
||||||
await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works without access token', async () => {
|
|
||||||
const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null });
|
|
||||||
globalThis.fetch = mockFetch(200, { plans: [] });
|
|
||||||
await noAuthClient.listPlans();
|
|
||||||
const headers = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers;
|
|
||||||
expect(headers['Authorization']).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
214
vendor/bytelyst/billing-client/src/index.ts
vendored
214
vendor/bytelyst/billing-client/src/index.ts
vendored
@ -1,214 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/billing-client — Browser/React Native-safe billing client
|
|
||||||
*
|
|
||||||
* Wraps platform-service /plans, /subscriptions, /payments, /usage
|
|
||||||
* endpoints with typed methods. Any ByteLyst product can add billing
|
|
||||||
* with minimal wiring:
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { createBillingClient } from '@bytelyst/billing-client';
|
|
||||||
*
|
|
||||||
* const billing = createBillingClient({
|
|
||||||
* baseUrl: 'http://localhost:4003/api',
|
|
||||||
* productId: 'notelett',
|
|
||||||
* getAccessToken: () => localStorage.getItem('notelett_access_token'),
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const plans = await billing.listPlans();
|
|
||||||
* const sub = await billing.getSubscription();
|
|
||||||
* await billing.changePlan('pro');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type PlanTier = 'free' | 'pro' | 'enterprise';
|
|
||||||
export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing';
|
|
||||||
export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded';
|
|
||||||
|
|
||||||
export interface PlanConfig {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
price: number;
|
|
||||||
tokens: number;
|
|
||||||
words: number;
|
|
||||||
dictations: number;
|
|
||||||
features: string[];
|
|
||||||
stripePriceId?: string;
|
|
||||||
active: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Subscription {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
userId: string;
|
|
||||||
plan: PlanTier;
|
|
||||||
status: SubscriptionStatus;
|
|
||||||
currentPeriodStart: string;
|
|
||||||
currentPeriodEnd: string;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
monthlyPrice: number;
|
|
||||||
tokensIncluded: number;
|
|
||||||
tokensUsed: number;
|
|
||||||
stripeCustomerId?: string;
|
|
||||||
stripeSubscriptionId?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Payment {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
userId: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
status: PaymentStatus;
|
|
||||||
description: string;
|
|
||||||
method: string;
|
|
||||||
invoiceUrl?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageSummary {
|
|
||||||
tokensUsed: number;
|
|
||||||
tokensIncluded: number;
|
|
||||||
tokensRemaining: number;
|
|
||||||
percentUsed: number;
|
|
||||||
currentPeriodStart: string;
|
|
||||||
currentPeriodEnd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BillingClientConfig {
|
|
||||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
|
||||||
baseUrl: string;
|
|
||||||
/** Product identifier. */
|
|
||||||
productId: string;
|
|
||||||
/** Returns current access token, or null if not authenticated. */
|
|
||||||
getAccessToken: () => string | null;
|
|
||||||
/** Request timeout in ms (default: 15000). */
|
|
||||||
timeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Client Interface ─────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BillingClient {
|
|
||||||
/** List available plans for the product. */
|
|
||||||
listPlans(): Promise<PlanConfig[]>;
|
|
||||||
/** Get a specific plan by name. */
|
|
||||||
getPlan(planName: string): Promise<PlanConfig>;
|
|
||||||
/** Get current user's subscription. Returns null if no subscription. */
|
|
||||||
getSubscription(): Promise<Subscription | null>;
|
|
||||||
/** Change to a different plan (creates or updates subscription). */
|
|
||||||
changePlan(plan: PlanTier): Promise<Subscription>;
|
|
||||||
/** Cancel subscription at period end. */
|
|
||||||
cancelSubscription(): Promise<Subscription>;
|
|
||||||
/** Resume a cancelled subscription (undo cancel-at-period-end). */
|
|
||||||
resumeSubscription(): Promise<Subscription>;
|
|
||||||
/** List payment history. */
|
|
||||||
listPayments(): Promise<Payment[]>;
|
|
||||||
/** Get usage summary for current billing period. */
|
|
||||||
getUsage(): Promise<UsageSummary>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Errors ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class BillingApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
public readonly body: unknown,
|
|
||||||
message?: string
|
|
||||||
) {
|
|
||||||
super(message ?? `Billing API error ${status}`);
|
|
||||||
this.name = 'BillingApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Factory ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function createBillingClient(config: BillingClientConfig): BillingClient {
|
|
||||||
const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config;
|
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-product-id': productId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const token = getAccessToken();
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await globalThis.fetch(`${baseUrl}${path}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
if (res.status === 404) return null as T;
|
|
||||||
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new BillingApiError(
|
|
||||||
res.status,
|
|
||||||
json,
|
|
||||||
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json as T;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
async listPlans() {
|
|
||||||
const result = await request<{ plans: PlanConfig[] }>('GET', '/plans');
|
|
||||||
return result.plans;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPlan(planName: string) {
|
|
||||||
return request<PlanConfig>('GET', `/plans/${encodeURIComponent(planName)}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSubscription() {
|
|
||||||
// Platform-service expects userId in path; the userId comes from the JWT.
|
|
||||||
// We use a convenience endpoint that reads userId from the token.
|
|
||||||
return request<Subscription | null>('GET', '/subscriptions/me');
|
|
||||||
},
|
|
||||||
|
|
||||||
async changePlan(plan: PlanTier) {
|
|
||||||
return request<Subscription>('POST', '/subscriptions/me/change-plan', { plan });
|
|
||||||
},
|
|
||||||
|
|
||||||
async cancelSubscription() {
|
|
||||||
return request<Subscription>('POST', '/subscriptions/me/cancel');
|
|
||||||
},
|
|
||||||
|
|
||||||
async resumeSubscription() {
|
|
||||||
return request<Subscription>('POST', '/subscriptions/me/resume');
|
|
||||||
},
|
|
||||||
|
|
||||||
async listPayments() {
|
|
||||||
const result = await request<{ payments: Payment[] }>('GET', '/payments/me');
|
|
||||||
return result.payments;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsage() {
|
|
||||||
return request<UsageSummary>('GET', '/subscriptions/me/usage');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
8
vendor/bytelyst/billing-client/tsconfig.json
vendored
8
vendor/bytelyst/billing-client/tsconfig.json
vendored
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
24
vendor/bytelyst/blob-client/package.json
vendored
24
vendor/bytelyst/blob-client/package.json
vendored
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/blob-client",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Browser/React Native-safe blob storage client — SAS URL upload/download via platform-service",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
276
vendor/bytelyst/blob-client/src/index.test.ts
vendored
276
vendor/bytelyst/blob-client/src/index.test.ts
vendored
@ -1,276 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createBlobClient, BlobApiError, BlobUploadError } from './index.js';
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
globalThis.fetch = mockFetch;
|
|
||||||
|
|
||||||
function jsonResponse(data: unknown, status = 200): Response {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: () => Promise.resolve(data),
|
|
||||||
headers: new Headers(),
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function blobClient(overrides?: Partial<Parameters<typeof createBlobClient>[0]>) {
|
|
||||||
return createBlobClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAccessToken: () => 'test-token',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createBlobClient', () => {
|
|
||||||
describe('getSasUrl', () => {
|
|
||||||
it('requests a SAS URL from platform-service', async () => {
|
|
||||||
const sasResponse = {
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'testapp/user1/photo.jpg',
|
|
||||||
permissions: 'r',
|
|
||||||
expiresInMinutes: 60,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(sasResponse));
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const result = await client.getSasUrl('attachments', 'testapp/user1/photo.jpg');
|
|
||||||
|
|
||||||
expect(result).toEqual(sasResponse);
|
|
||||||
expect(mockFetch).toHaveBeenCalledOnce();
|
|
||||||
|
|
||||||
const [url, init] = mockFetch.mock.calls[0];
|
|
||||||
expect(url).toBe('http://localhost:4003/api/blob/sas');
|
|
||||||
expect(init.method).toBe('POST');
|
|
||||||
expect(JSON.parse(init.body)).toEqual({
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'testapp/user1/photo.jpg',
|
|
||||||
permissions: 'r',
|
|
||||||
expiresInMinutes: 60,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends auth and product headers', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' }));
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
await client.getSasUrl('attachments', 'blob.jpg');
|
|
||||||
|
|
||||||
const headers = mockFetch.mock.calls[0][1].headers;
|
|
||||||
expect(headers['Authorization']).toBe('Bearer test-token');
|
|
||||||
expect(headers['x-product-id']).toBe('testapp');
|
|
||||||
expect(headers['x-request-id']).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits auth header when no token', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ sasUrl: 'https://x' }));
|
|
||||||
|
|
||||||
const client = blobClient({ getAccessToken: () => null });
|
|
||||||
await client.getSasUrl('attachments', 'blob.jpg');
|
|
||||||
|
|
||||||
const headers = mockFetch.mock.calls[0][1].headers;
|
|
||||||
expect(headers['Authorization']).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws BlobApiError on non-ok response', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401));
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
await expect(client.getSasUrl('releases', 'secret.bin')).rejects.toThrow(BlobApiError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('upload', () => {
|
|
||||||
it('requests SAS then uploads directly to Azure', async () => {
|
|
||||||
const sasResponse = {
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg?sig=abc',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'testapp/user1/photo.jpg',
|
|
||||||
permissions: 'w',
|
|
||||||
expiresInMinutes: 30,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(jsonResponse(sasResponse)) // SAS request
|
|
||||||
.mockResolvedValueOnce({ ok: true, status: 201 } as Response); // Azure upload
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const result = await client.upload('attachments', 'file-data', {
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
blobName: 'testapp/user1/photo.jpg',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.container).toBe('attachments');
|
|
||||||
expect(result.blobName).toBe('testapp/user1/photo.jpg');
|
|
||||||
expect(result.sasUrl).toBe(
|
|
||||||
'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify Azure upload call
|
|
||||||
const [uploadUrl, uploadInit] = mockFetch.mock.calls[1];
|
|
||||||
expect(uploadUrl).toBe(sasResponse.sasUrl);
|
|
||||||
expect(uploadInit.method).toBe('PUT');
|
|
||||||
expect(uploadInit.headers['x-ms-blob-type']).toBe('BlockBlob');
|
|
||||||
expect(uploadInit.headers['Content-Type']).toBe('image/jpeg');
|
|
||||||
expect(uploadInit.body).toBe('file-data');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('auto-generates blobName when not provided', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/testapp/123-abc?sig=x',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'testapp/123-abc',
|
|
||||||
permissions: 'w',
|
|
||||||
expiresInMinutes: 30,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({ ok: true, status: 201 } as Response);
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const result = await client.upload('attachments', 'data', {
|
|
||||||
contentType: 'text/plain',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify the SAS request included a generated blobName starting with productId
|
|
||||||
const sasBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
||||||
expect(sasBody.blobName).toMatch(/^testapp\//);
|
|
||||||
expect(result.container).toBe('attachments');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws BlobUploadError when Azure returns non-ok', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=abc',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'blob',
|
|
||||||
permissions: 'w',
|
|
||||||
expiresInMinutes: 30,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 403 } as Response);
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
await expect(
|
|
||||||
client.upload('attachments', 'data', { contentType: 'text/plain', blobName: 'blob' })
|
|
||||||
).rejects.toThrow(BlobUploadError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('download', () => {
|
|
||||||
it('requests read SAS then fetches the blob', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'blob',
|
|
||||||
permissions: 'r',
|
|
||||||
expiresInMinutes: 15,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
blob: () => Promise.resolve(new Blob()),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const res = await client.download('attachments', 'blob');
|
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
// Second call should be to the SAS URL
|
|
||||||
expect(mockFetch.mock.calls[1][0]).toBe(
|
|
||||||
'https://storage.blob.core.windows.net/attachments/blob?sig=read'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws BlobApiError on download failure', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
sasUrl: 'https://storage.blob.core.windows.net/attachments/blob?sig=read',
|
|
||||||
container: 'attachments',
|
|
||||||
blobName: 'blob',
|
|
||||||
permissions: 'r',
|
|
||||||
expiresInMinutes: 15,
|
|
||||||
expiresAt: '2026-03-02T00:00:00.000Z',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
await expect(client.download('attachments', 'blob')).rejects.toThrow(BlobApiError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('list', () => {
|
|
||||||
it('lists blobs with prefix and limit', async () => {
|
|
||||||
const listResponse = {
|
|
||||||
blobs: [{ name: 'testapp/user1/photo.jpg', container: 'attachments', size: 1024 }],
|
|
||||||
count: 1,
|
|
||||||
container: 'attachments',
|
|
||||||
prefix: 'testapp/user1/',
|
|
||||||
};
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(listResponse));
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const result = await client.list('attachments', { prefix: 'testapp/user1/', limit: 10 });
|
|
||||||
|
|
||||||
expect(result).toEqual(listResponse);
|
|
||||||
const url = mockFetch.mock.calls[0][0] as string;
|
|
||||||
expect(url).toContain('container=attachments');
|
|
||||||
expect(url).toContain('prefix=testapp%2Fuser1%2F');
|
|
||||||
expect(url).toContain('limit=10');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works without options', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
jsonResponse({ blobs: [], count: 0, container: 'audio', prefix: null })
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
await client.list('audio');
|
|
||||||
|
|
||||||
const url = mockFetch.mock.calls[0][0] as string;
|
|
||||||
expect(url).toContain('container=audio');
|
|
||||||
expect(url).not.toContain('prefix=');
|
|
||||||
expect(url).not.toContain('limit=');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('info', () => {
|
|
||||||
it('fetches blob metadata', async () => {
|
|
||||||
const infoResponse = {
|
|
||||||
name: 'testapp/user1/photo.jpg',
|
|
||||||
container: 'attachments',
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
size: 2048,
|
|
||||||
lastModified: '2026-03-01T00:00:00.000Z',
|
|
||||||
url: 'https://storage.blob.core.windows.net/attachments/testapp/user1/photo.jpg',
|
|
||||||
metadata: {},
|
|
||||||
};
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse(infoResponse));
|
|
||||||
|
|
||||||
const client = blobClient();
|
|
||||||
const result = await client.info('attachments', 'testapp/user1/photo.jpg');
|
|
||||||
|
|
||||||
expect(result).toEqual(infoResponse);
|
|
||||||
const url = mockFetch.mock.calls[0][0] as string;
|
|
||||||
expect(url).toContain('/blob/info/attachments/testapp%2Fuser1%2Fphoto.jpg');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
289
vendor/bytelyst/blob-client/src/index.ts
vendored
289
vendor/bytelyst/blob-client/src/index.ts
vendored
@ -1,289 +0,0 @@
|
|||||||
/**
|
|
||||||
* Browser/React Native-safe blob storage client.
|
|
||||||
*
|
|
||||||
* Wraps the platform-service blob endpoints to provide:
|
|
||||||
* - SAS URL generation for direct upload/download
|
|
||||||
* - Direct blob upload via SAS URL (PUT with raw body)
|
|
||||||
* - Direct blob download via SAS URL
|
|
||||||
* - Blob listing and metadata
|
|
||||||
*
|
|
||||||
* Requires a fetch-compatible environment (browser, React Native, Node 18+).
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* import { createBlobClient } from '@bytelyst/blob-client';
|
|
||||||
*
|
|
||||||
* const blob = createBlobClient({
|
|
||||||
* baseUrl: 'http://localhost:4003/api',
|
|
||||||
* productId: 'nomgap',
|
|
||||||
* getAccessToken: () => authClient.getAccessToken(),
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Upload a file
|
|
||||||
* const { url } = await blob.upload('attachments', file, {
|
|
||||||
* contentType: 'image/jpeg',
|
|
||||||
* blobName: 'nomgap/user123/photos/meal.jpg',
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Download a file
|
|
||||||
* const data = await blob.download('attachments', 'nomgap/user123/photos/meal.jpg');
|
|
||||||
*
|
|
||||||
* // List blobs
|
|
||||||
* const { blobs } = await blob.list('attachments', { prefix: 'nomgap/user123/' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BlobClientConfig {
|
|
||||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
|
||||||
baseUrl: string;
|
|
||||||
|
|
||||||
/** Product identifier sent as x-product-id header. */
|
|
||||||
productId: string;
|
|
||||||
|
|
||||||
/** Function that returns the current access token, or null. */
|
|
||||||
getAccessToken: () => string | null;
|
|
||||||
|
|
||||||
/** Request timeout in milliseconds for API calls. Default: 15000. */
|
|
||||||
timeoutMs?: number;
|
|
||||||
|
|
||||||
/** Upload timeout in milliseconds for direct blob uploads. Default: 120000. */
|
|
||||||
uploadTimeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SasUrlResponse {
|
|
||||||
sasUrl: string;
|
|
||||||
container: string;
|
|
||||||
blobName: string;
|
|
||||||
permissions: string;
|
|
||||||
expiresInMinutes: number;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlobInfo {
|
|
||||||
name: string;
|
|
||||||
container: string;
|
|
||||||
contentType?: string;
|
|
||||||
size: number;
|
|
||||||
lastModified?: string;
|
|
||||||
url: string;
|
|
||||||
metadata: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListBlobsResponse {
|
|
||||||
blobs: BlobInfo[];
|
|
||||||
count: number;
|
|
||||||
container: string;
|
|
||||||
prefix: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadOptions {
|
|
||||||
/** Content-Type of the blob (e.g. "image/jpeg", "application/pdf"). */
|
|
||||||
contentType: string;
|
|
||||||
|
|
||||||
/** Full blob path. If omitted, auto-generated as `<productId>/<userId>/<timestamp>-<random>`. */
|
|
||||||
blobName?: string;
|
|
||||||
|
|
||||||
/** SAS token expiry in minutes. Default: 30. */
|
|
||||||
expiresInMinutes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResult {
|
|
||||||
sasUrl: string;
|
|
||||||
container: string;
|
|
||||||
blobName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlobClient {
|
|
||||||
/** Get a SAS URL for direct upload or download. */
|
|
||||||
getSasUrl(
|
|
||||||
container: string,
|
|
||||||
blobName: string,
|
|
||||||
permissions?: 'r' | 'w' | 'rw' | 'rwc',
|
|
||||||
expiresInMinutes?: number
|
|
||||||
): Promise<SasUrlResponse>;
|
|
||||||
|
|
||||||
/** Upload a blob via SAS URL (requests SAS, then PUTs directly to Azure). */
|
|
||||||
upload(
|
|
||||||
container: string,
|
|
||||||
data: Blob | ArrayBuffer | Uint8Array | string,
|
|
||||||
options: UploadOptions
|
|
||||||
): Promise<UploadResult>;
|
|
||||||
|
|
||||||
/** Download a blob via SAS URL. Returns the Response for streaming. */
|
|
||||||
download(container: string, blobName: string): Promise<Response>;
|
|
||||||
|
|
||||||
/** List blobs in a container. */
|
|
||||||
list(
|
|
||||||
container: string,
|
|
||||||
options?: { prefix?: string; limit?: number }
|
|
||||||
): Promise<ListBlobsResponse>;
|
|
||||||
|
|
||||||
/** Get blob metadata/info. */
|
|
||||||
info(container: string, blobName: string): Promise<BlobInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Errors ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class BlobApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
public readonly body: unknown,
|
|
||||||
message?: string
|
|
||||||
) {
|
|
||||||
super(message ?? `Blob API error ${status}`);
|
|
||||||
this.name = 'BlobApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlobUploadError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
message?: string
|
|
||||||
) {
|
|
||||||
super(message ?? `Blob upload failed with status ${status}`);
|
|
||||||
this.name = 'BlobUploadError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UUID helper ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
function uuid(): string {
|
|
||||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
||||||
return globalThis.crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Factory ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function createBlobClient(config: BlobClientConfig): BlobClient {
|
|
||||||
const {
|
|
||||||
baseUrl,
|
|
||||||
productId,
|
|
||||||
getAccessToken,
|
|
||||||
timeoutMs = 15_000,
|
|
||||||
uploadTimeoutMs = 120_000,
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-product-id': productId,
|
|
||||||
'x-request-id': uuid(),
|
|
||||||
};
|
|
||||||
const token = getAccessToken();
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
||||||
const url = `${baseUrl}${path}`;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await globalThis.fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: authHeaders(),
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new BlobApiError(
|
|
||||||
res.status,
|
|
||||||
json,
|
|
||||||
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json as T;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSasUrl(
|
|
||||||
container: string,
|
|
||||||
blobName: string,
|
|
||||||
permissions: 'r' | 'w' | 'rw' | 'rwc' = 'r',
|
|
||||||
expiresInMinutes = 60
|
|
||||||
): Promise<SasUrlResponse> {
|
|
||||||
return apiRequest<SasUrlResponse>('POST', '/blob/sas', {
|
|
||||||
container,
|
|
||||||
blobName,
|
|
||||||
permissions,
|
|
||||||
expiresInMinutes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upload(
|
|
||||||
container: string,
|
|
||||||
data: Blob | ArrayBuffer | Uint8Array | string,
|
|
||||||
options: UploadOptions
|
|
||||||
): Promise<UploadResult> {
|
|
||||||
const blobName = options.blobName ?? `${productId}/${Date.now()}-${uuid().slice(0, 8)}`;
|
|
||||||
|
|
||||||
const sas = await getSasUrl(container, blobName, 'w', options.expiresInMinutes ?? 30);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), uploadTimeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await globalThis.fetch(sas.sasUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'x-ms-blob-type': 'BlockBlob',
|
|
||||||
'Content-Type': options.contentType,
|
|
||||||
},
|
|
||||||
body: data as BodyInit,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new BlobUploadError(res.status, `Upload to Azure failed: HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sasUrl: sas.sasUrl.split('?')[0], container, blobName };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function download(container: string, blobName: string): Promise<Response> {
|
|
||||||
const sas = await getSasUrl(container, blobName, 'r', 15);
|
|
||||||
|
|
||||||
const res = await globalThis.fetch(sas.sasUrl);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new BlobApiError(res.status, null, `Download failed: HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function list(
|
|
||||||
container: string,
|
|
||||||
options?: { prefix?: string; limit?: number }
|
|
||||||
): Promise<ListBlobsResponse> {
|
|
||||||
const params = new URLSearchParams({ container });
|
|
||||||
if (options?.prefix) params.set('prefix', options.prefix);
|
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
|
||||||
|
|
||||||
return apiRequest<ListBlobsResponse>('GET', `/blob/list?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function info(container: string, blobName: string): Promise<BlobInfo> {
|
|
||||||
return apiRequest<BlobInfo>(
|
|
||||||
'GET',
|
|
||||||
`/blob/info/${encodeURIComponent(container)}/${encodeURIComponent(blobName)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { getSasUrl, upload, download, list, info };
|
|
||||||
}
|
|
||||||
10
vendor/bytelyst/blob-client/tsconfig.json
vendored
10
vendor/bytelyst/blob-client/tsconfig.json
vendored
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"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
27
vendor/bytelyst/blob/package.json
vendored
@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/blob",
|
|
||||||
"version": "0.2.5",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"pretest": "pnpm --dir ../.. --filter @bytelyst/storage build",
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@bytelyst/storage": "workspace:*"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
108
vendor/bytelyst/blob/src/__tests__/blob.test.ts
vendored
108
vendor/bytelyst/blob/src/__tests__/blob.test.ts
vendored
@ -1,108 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import { setStorage, MemoryStorageProvider } from '@bytelyst/storage';
|
|
||||||
|
|
||||||
import {
|
|
||||||
_resetBlobClient,
|
|
||||||
generateSasUrl,
|
|
||||||
getBucket,
|
|
||||||
getStorageProvider,
|
|
||||||
isBlobStorageConfigured,
|
|
||||||
BLOB_CONTAINERS,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
describe('blob', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
_resetBlobClient();
|
|
||||||
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
|
||||||
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
|
||||||
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
|
||||||
delete process.env.STORAGE_PROVIDER;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
_resetBlobClient();
|
|
||||||
delete process.env.AZURE_BLOB_CONNECTION_STRING;
|
|
||||||
delete process.env.AZURE_BLOB_ACCOUNT_NAME;
|
|
||||||
delete process.env.AZURE_BLOB_ACCOUNT_KEY;
|
|
||||||
delete process.env.STORAGE_PROVIDER;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isBlobStorageConfigured', () => {
|
|
||||||
it('is false when unset', () => {
|
|
||||||
expect(isBlobStorageConfigured()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when connection string is set', () => {
|
|
||||||
process.env.AZURE_BLOB_CONNECTION_STRING = 'AccountName=x;AccountKey=y;';
|
|
||||||
expect(isBlobStorageConfigured()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when account name + key are set', () => {
|
|
||||||
process.env.AZURE_BLOB_ACCOUNT_NAME = 'acc';
|
|
||||||
process.env.AZURE_BLOB_ACCOUNT_KEY = 'key==';
|
|
||||||
expect(isBlobStorageConfigured()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when provider is memory', () => {
|
|
||||||
process.env.STORAGE_PROVIDER = 'memory';
|
|
||||||
expect(isBlobStorageConfigured()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with memory provider', () => {
|
|
||||||
let memoryProvider: MemoryStorageProvider;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
memoryProvider = new MemoryStorageProvider();
|
|
||||||
setStorage(memoryProvider);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getStorageProvider returns the provider', async () => {
|
|
||||||
const provider = await getStorageProvider();
|
|
||||||
expect(provider).toBe(memoryProvider);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getBucket returns a bucket by name', async () => {
|
|
||||||
const bucket = await getBucket('audio');
|
|
||||||
expect(bucket).toBeDefined();
|
|
||||||
// Upload and download to verify it works
|
|
||||||
await bucket.upload('test.wav', Buffer.from('hello'));
|
|
||||||
const data = await bucket.download('test.wav');
|
|
||||||
expect(data.toString()).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generateSasUrl returns a signed URL', async () => {
|
|
||||||
const url = await generateSasUrl('audio', 'path/file.wav', 'r', 10);
|
|
||||||
expect(url).toContain('audio');
|
|
||||||
expect(url).toContain('path/file.wav');
|
|
||||||
expect(url).toContain('signed=true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generateSasUrl defaults to read permissions', async () => {
|
|
||||||
const url = await generateSasUrl('audio', 'file.wav');
|
|
||||||
expect(url).toContain('signed=true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_resetBlobClient resets the storage singleton', async () => {
|
|
||||||
const p1 = await getStorageProvider();
|
|
||||||
_resetBlobClient();
|
|
||||||
// After reset, inject a new provider
|
|
||||||
const newProvider = new MemoryStorageProvider();
|
|
||||||
setStorage(newProvider);
|
|
||||||
const p2 = await getStorageProvider();
|
|
||||||
expect(p1).not.toBe(p2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BLOB_CONTAINERS', () => {
|
|
||||||
it('has expected container names', () => {
|
|
||||||
expect(BLOB_CONTAINERS.audio).toBe('audio');
|
|
||||||
expect(BLOB_CONTAINERS.transcripts).toBe('transcripts');
|
|
||||||
expect(BLOB_CONTAINERS.attachments).toBe('attachments');
|
|
||||||
expect(BLOB_CONTAINERS.avatars).toBe('avatars');
|
|
||||||
expect(BLOB_CONTAINERS.releases).toBe('releases');
|
|
||||||
expect(BLOB_CONTAINERS.backups).toBe('backups');
|
|
||||||
expect(BLOB_CONTAINERS.feedbackScreenshots).toBe('feedback-screenshots');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
94
vendor/bytelyst/blob/src/blob.ts
vendored
94
vendor/bytelyst/blob/src/blob.ts
vendored
@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared Blob Storage utilities.
|
|
||||||
*
|
|
||||||
* Delegates to @bytelyst/storage for provider-agnostic blob operations.
|
|
||||||
* Keeps the same exported API surface for backward compatibility.
|
|
||||||
*
|
|
||||||
* Expected env vars:
|
|
||||||
* STORAGE_PROVIDER — 'azure' (default) | 'memory'
|
|
||||||
* AZURE_BLOB_CONNECTION_STRING — full connection string (preferred, when provider=azure)
|
|
||||||
* — OR —
|
|
||||||
* AZURE_BLOB_ACCOUNT_NAME + AZURE_BLOB_ACCOUNT_KEY
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
getStorage,
|
|
||||||
_resetStorage,
|
|
||||||
type StorageProvider,
|
|
||||||
type StorageBucket,
|
|
||||||
} from '@bytelyst/storage';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Known blob containers and their purposes.
|
|
||||||
*
|
|
||||||
* Note: This is a convenience list (not enforced). Products can add their own
|
|
||||||
* containers as needed.
|
|
||||||
*/
|
|
||||||
export const BLOB_CONTAINERS = {
|
|
||||||
audio: 'audio', // Dictation audio recordings
|
|
||||||
transcripts: 'transcripts', // Exported transcript files (PDF, DOCX, TXT)
|
|
||||||
attachments: 'attachments', // Tracker item attachments (screenshots, docs)
|
|
||||||
avatars: 'avatars', // User profile images
|
|
||||||
releases: 'releases', // Desktop app update binaries
|
|
||||||
backups: 'backups', // Cosmos DB JSON backups
|
|
||||||
feedbackScreenshots: 'feedback-screenshots', // User feedback screenshot attachments
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type BlobContainerName = (typeof BLOB_CONTAINERS)[keyof typeof BLOB_CONTAINERS];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the storage provider singleton.
|
|
||||||
*/
|
|
||||||
export async function getStorageProvider(): Promise<StorageProvider> {
|
|
||||||
return getStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a bucket (container) by name.
|
|
||||||
*/
|
|
||||||
export async function getBucket(containerName: string): Promise<StorageBucket> {
|
|
||||||
const storage = await getStorage();
|
|
||||||
return storage.getBucket(containerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a signed URL for direct browser upload (or download).
|
|
||||||
*
|
|
||||||
* @param containerName - Target container
|
|
||||||
* @param blobName - Full blob path (e.g., "product/user123/audio/recording.wav")
|
|
||||||
* @param permissions - SAS permissions (default: read)
|
|
||||||
* @param expiresInMinutes - Token lifetime (default: 60)
|
|
||||||
* @returns Full signed URL for the blob
|
|
||||||
*/
|
|
||||||
export async function generateSasUrl(
|
|
||||||
containerName: string,
|
|
||||||
blobName: string,
|
|
||||||
permissions: 'r' | 'w' | 'rw' | 'rwc' | 'rwd' = 'r',
|
|
||||||
expiresInMinutes = 60
|
|
||||||
): Promise<string> {
|
|
||||||
const bucket = await getBucket(containerName);
|
|
||||||
const perm = permissions.includes('w') ? ('write' as const) : ('read' as const);
|
|
||||||
return bucket.getSignedUrl(blobName, {
|
|
||||||
permissions: perm,
|
|
||||||
expiresIn: expiresInMinutes * 60,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if blob storage is configured.
|
|
||||||
*/
|
|
||||||
export function isBlobStorageConfigured(): boolean {
|
|
||||||
const provider = process.env.STORAGE_PROVIDER || 'azure';
|
|
||||||
if (provider === 'memory') return true;
|
|
||||||
return !!(
|
|
||||||
process.env.AZURE_BLOB_CONNECTION_STRING ||
|
|
||||||
(process.env.AZURE_BLOB_ACCOUNT_NAME && process.env.AZURE_BLOB_ACCOUNT_KEY)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test helper: reset module singletons/caches.
|
|
||||||
*/
|
|
||||||
export function _resetBlobClient(): void {
|
|
||||||
_resetStorage();
|
|
||||||
}
|
|
||||||
1
vendor/bytelyst/blob/src/index.ts
vendored
1
vendor/bytelyst/blob/src/index.ts
vendored
@ -1 +0,0 @@
|
|||||||
export * from './blob.js';
|
|
||||||
9
vendor/bytelyst/blob/tsconfig.json
vendored
9
vendor/bytelyst/blob/tsconfig.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
227
vendor/bytelyst/broadcast-client/README.md
vendored
227
vendor/bytelyst/broadcast-client/README.md
vendored
@ -1,227 +0,0 @@
|
|||||||
# @bytelyst/broadcast-client
|
|
||||||
|
|
||||||
TypeScript client for the ByteLyst Broadcast & Messaging platform. Provides in-app message polling, read receipts, and push notification token management.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @bytelyst/broadcast-client
|
|
||||||
# or
|
|
||||||
pnpm add @bytelyst/broadcast-client
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createBroadcastClient } from '@bytelyst/broadcast-client';
|
|
||||||
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseURL: 'https://api.bytelyst.io/v1',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
getAuthToken: async () => {
|
|
||||||
// Return your JWT token
|
|
||||||
return localStorage.getItem('token');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start polling for messages (every 60 seconds)
|
|
||||||
client.startPolling(60000, (messages) => {
|
|
||||||
console.log('New messages:', messages);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### `createBroadcastClient(config)`
|
|
||||||
|
|
||||||
Creates a new broadcast client instance.
|
|
||||||
|
|
||||||
**Config:**
|
|
||||||
| Option | Type | Required | Description |
|
|
||||||
|--------|------|----------|-------------|
|
|
||||||
| `baseURL` | string | Yes | API base URL |
|
|
||||||
| `productId` | string | Yes | Product identifier |
|
|
||||||
| `getAuthToken` | () => Promise<string> | Yes | Function to retrieve JWT token |
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
#### `getMessages()`
|
|
||||||
Fetch active in-app messages for the current user.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { data, error } = await client.getMessages();
|
|
||||||
// Returns: { messages: InAppMessage[] }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `markRead(messageId: string)`
|
|
||||||
Mark a message as read.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.markRead('msg_123');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `markDismissed(messageId: string)`
|
|
||||||
Dismiss a message.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.markDismissed('msg_123');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `trackClick(messageId: string)`
|
|
||||||
Track when user clicks/taps a message CTA.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.trackClick('msg_123');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `startPolling(intervalMs: number, callback: (messages) => void)`
|
|
||||||
Start polling for new messages.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
client.startPolling(60000, (messages) => {
|
|
||||||
// Called every 60 seconds with current messages
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `stopPolling()`
|
|
||||||
Stop message polling.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
client.stopPolling();
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `registerDeviceToken(token: string, platform: 'ios' | 'android' | 'web')`
|
|
||||||
Register push notification device token.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.registerDeviceToken('fcm_token_xyz', 'android');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `unregisterDeviceToken(token: string)`
|
|
||||||
Unregister device token (e.g., on logout).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await client.unregisterDeviceToken('fcm_token_xyz');
|
|
||||||
```
|
|
||||||
|
|
||||||
## React Integration
|
|
||||||
|
|
||||||
### Hook Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useBroadcastClient } from './hooks/useBroadcastClient';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const { messages, unreadCount, markRead, markDismissed } = useBroadcastClient({
|
|
||||||
pollingInterval: 60000
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{messages.map(msg => (
|
|
||||||
<Banner
|
|
||||||
key={msg.id}
|
|
||||||
title={msg.title}
|
|
||||||
body={msg.body}
|
|
||||||
onDismiss={() => markDismissed(msg.id)}
|
|
||||||
onClick={() => markRead(msg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Provider Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BroadcastProvider.tsx
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
import { createBroadcastClient, BroadcastClient, InAppMessage } from '@bytelyst/broadcast-client';
|
|
||||||
|
|
||||||
const BroadcastContext = createContext<BroadcastContextType | null>(null);
|
|
||||||
|
|
||||||
export function BroadcastProvider({ children, config }: { children: React.ReactNode; config: BroadcastConfig }) {
|
|
||||||
const [client] = useState(() => createBroadcastClient(config));
|
|
||||||
const [messages, setMessages] = useState<InAppMessage[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
client.startPolling(60000, setMessages);
|
|
||||||
return () => client.stopPolling();
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
messages,
|
|
||||||
unreadCount: messages.filter(m => m.status === 'unread').length,
|
|
||||||
markRead: client.markRead.bind(client),
|
|
||||||
markDismissed: client.markDismissed.bind(client),
|
|
||||||
trackClick: client.trackClick.bind(client),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BroadcastContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</BroadcastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBroadcast = () => {
|
|
||||||
const ctx = useContext(BroadcastContext);
|
|
||||||
if (!ctx) throw new Error('useBroadcast must be used within BroadcastProvider');
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface InAppMessage {
|
|
||||||
id: string;
|
|
||||||
broadcastId: string;
|
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
style: 'banner' | 'modal' | 'fullscreen' | 'toast';
|
|
||||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
ctaText?: string;
|
|
||||||
ctaUrl?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
deepLink?: {
|
|
||||||
screen: string;
|
|
||||||
params: Record<string, string>;
|
|
||||||
};
|
|
||||||
status: 'unread' | 'read' | 'dismissed';
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BroadcastConfig {
|
|
||||||
baseURL: string;
|
|
||||||
productId: string;
|
|
||||||
getAuthToken: () => Promise<string>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All methods return a result tuple `[data, error]`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [data, error] = await client.getMessages();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Failed to fetch messages:', error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use data.messages
|
|
||||||
```
|
|
||||||
|
|
||||||
## Browser Support
|
|
||||||
|
|
||||||
- Chrome 90+
|
|
||||||
- Firefox 88+
|
|
||||||
- Safari 14+
|
|
||||||
- Edge 90+
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT © ByteLyst
|
|
||||||
24
vendor/bytelyst/broadcast-client/package.json
vendored
24
vendor/bytelyst/broadcast-client/package.json
vendored
@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/broadcast-client",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Browser/React Native-safe broadcast messaging client for platform-service",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
165
vendor/bytelyst/broadcast-client/src/deep-link.ts
vendored
165
vendor/bytelyst/broadcast-client/src/deep-link.ts
vendored
@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Deep Link Router — TypeScript
|
|
||||||
* Handles routing from push notification deep links to app screens
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DeepLinkRoute {
|
|
||||||
screen: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeepLinkHandler = (route: DeepLinkRoute) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep Link Router class
|
|
||||||
*/
|
|
||||||
export class DeepLinkRouter {
|
|
||||||
private handlers = new Map<string, DeepLinkHandler>();
|
|
||||||
private fallbackHandler?: DeepLinkHandler;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a handler for a specific screen
|
|
||||||
*/
|
|
||||||
register(screen: string, handler: DeepLinkHandler): void {
|
|
||||||
this.handlers.set(screen, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a fallback handler for unregistered screens
|
|
||||||
*/
|
|
||||||
setFallback(handler: DeepLinkHandler): void {
|
|
||||||
this.fallbackHandler = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a deep link URL and extract route
|
|
||||||
*/
|
|
||||||
parseDeepLink(url: string): DeepLinkRoute | null {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
|
|
||||||
// Handle app-specific URLs: myapp://screen/params
|
|
||||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
|
||||||
const routeParts = [urlObj.host, ...urlObj.pathname.split('/').filter(Boolean)].filter(
|
|
||||||
Boolean
|
|
||||||
);
|
|
||||||
const screen = routeParts[0] || 'home';
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Parse query params
|
|
||||||
urlObj.searchParams.forEach((value, key) => {
|
|
||||||
params[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { screen, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle web URLs with deep link params
|
|
||||||
const deepLinkParam = urlObj.searchParams.get('dl');
|
|
||||||
if (deepLinkParam) {
|
|
||||||
return this.parseDeepLink(deepLinkParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle path-based routing: /screen/params
|
|
||||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
||||||
if (pathParts.length > 0) {
|
|
||||||
const screen = pathParts[0];
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
|
|
||||||
urlObj.searchParams.forEach((value, key) => {
|
|
||||||
params[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { screen, params };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a deep link route
|
|
||||||
*/
|
|
||||||
handle(route: DeepLinkRoute): boolean {
|
|
||||||
const handler = this.handlers.get(route.screen);
|
|
||||||
|
|
||||||
if (handler) {
|
|
||||||
handler(route);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fallbackHandler) {
|
|
||||||
this.fallbackHandler(route);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-console -- Deep-link consumers need an opt-in diagnostic when a route is dropped.
|
|
||||||
console.warn(`[DeepLink] No handler for screen: ${route.screen}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a deep link URL end-to-end
|
|
||||||
*/
|
|
||||||
process(url: string): boolean {
|
|
||||||
const route = this.parseDeepLink(url);
|
|
||||||
if (!route) {
|
|
||||||
// eslint-disable-next-line no-console -- Parse failures are intentionally surfaced to host apps during integration.
|
|
||||||
console.warn(`[DeepLink] Failed to parse: ${url}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return this.handle(route);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a broadcast deep link URL
|
|
||||||
*/
|
|
||||||
export function createBroadcastDeepLink(
|
|
||||||
baseUrl: string,
|
|
||||||
screen: string,
|
|
||||||
params?: Record<string, string>,
|
|
||||||
broadcastId?: string
|
|
||||||
): string {
|
|
||||||
const url = new URL(baseUrl);
|
|
||||||
url.pathname = `/${screen}`;
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (broadcastId) {
|
|
||||||
url.searchParams.set('broadcastId', broadcastId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common deep link screens for broadcast/survey flows
|
|
||||||
*/
|
|
||||||
export const DeepLinkScreens = {
|
|
||||||
// Broadcasts
|
|
||||||
BROADCAST_DETAIL: 'broadcast',
|
|
||||||
ANNOUNCEMENTS: 'announcements',
|
|
||||||
|
|
||||||
// Surveys
|
|
||||||
SURVEY: 'survey',
|
|
||||||
SURVEY_LIST: 'surveys',
|
|
||||||
|
|
||||||
// Product-specific (examples)
|
|
||||||
SETTINGS: 'settings',
|
|
||||||
PROFILE: 'profile',
|
|
||||||
UPGRADE: 'upgrade',
|
|
||||||
SUPPORT: 'support',
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
HOME: 'home',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Singleton instance for app-wide use
|
|
||||||
export const deepLinkRouter = new DeepLinkRouter();
|
|
||||||
219
vendor/bytelyst/broadcast-client/src/index.test.ts
vendored
219
vendor/bytelyst/broadcast-client/src/index.test.ts
vendored
@ -1,219 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
DeepLinkRouter,
|
|
||||||
DeepLinkScreens,
|
|
||||||
createBroadcastClient,
|
|
||||||
createBroadcastDeepLink,
|
|
||||||
createUseBroadcast,
|
|
||||||
} from './index.js';
|
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200) {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(body)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createBroadcastClient', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchMock.mockReset();
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists messages with default segment and auth headers', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [{ id: 'm1' }] }));
|
|
||||||
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAuthToken: () => 'token-123',
|
|
||||||
platform: 'web',
|
|
||||||
appVersion: '1.2.3',
|
|
||||||
osVersion: 'macOS 15',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.listMessages();
|
|
||||||
|
|
||||||
expect(result).toEqual({ messages: [{ id: 'm1' }] });
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:4003/api/broadcasts',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer token-123',
|
|
||||||
'x-product-id': 'testapp',
|
|
||||||
'x-platform': 'web',
|
|
||||||
'x-app-version': '1.2.3',
|
|
||||||
'x-os-version': 'macOS 15',
|
|
||||||
'x-user-segments': 'free',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports async auth token resolution and optional headers', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse({ messages: [] }));
|
|
||||||
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAuthToken: async () => 'async-token',
|
|
||||||
platform: 'ios',
|
|
||||||
appVersion: '2.0.0',
|
|
||||||
osVersion: '18.0',
|
|
||||||
countryCode: 'US',
|
|
||||||
regionCode: 'CA',
|
|
||||||
userSegments: ['pro', 'beta'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.listMessages();
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:4003/api/broadcasts',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer async-token',
|
|
||||||
'x-country-code': 'US',
|
|
||||||
'x-region-code': 'CA',
|
|
||||||
'x-user-segments': 'pro,beta',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a descriptive error when the API fails', async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
text: () => Promise.resolve('boom'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAuthToken: () => 'token-123',
|
|
||||||
platform: 'web',
|
|
||||||
appVersion: '1.2.3',
|
|
||||||
osVersion: 'macOS 15',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(client.markRead('message-1')).rejects.toThrow('Broadcast API error: 500 boom');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('polls messages and returns a cleanup function', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
fetchMock.mockResolvedValue(jsonResponse({ messages: [] }));
|
|
||||||
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAuthToken: () => 'token-123',
|
|
||||||
platform: 'web',
|
|
||||||
appVersion: '1.2.3',
|
|
||||||
osVersion: 'macOS 15',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopPolling = client.pollMessages(1000);
|
|
||||||
await vi.advanceTimersByTimeAsync(3000);
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
||||||
|
|
||||||
stopPolling();
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the same client from createUseBroadcast', () => {
|
|
||||||
const client = createBroadcastClient({
|
|
||||||
baseUrl: 'http://localhost:4003/api',
|
|
||||||
productId: 'testapp',
|
|
||||||
getAuthToken: () => 'token-123',
|
|
||||||
platform: 'web',
|
|
||||||
appVersion: '1.2.3',
|
|
||||||
osVersion: 'macOS 15',
|
|
||||||
});
|
|
||||||
|
|
||||||
const useBroadcast = createUseBroadcast(client);
|
|
||||||
|
|
||||||
expect(useBroadcast()).toEqual({ client });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DeepLinkRouter', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses custom app deep links using the host as the screen', () => {
|
|
||||||
const router = new DeepLinkRouter();
|
|
||||||
|
|
||||||
expect(router.parseDeepLink('myapp://broadcast?broadcastId=b1&variant=test')).toEqual({
|
|
||||||
screen: 'broadcast',
|
|
||||||
params: {
|
|
||||||
broadcastId: 'b1',
|
|
||||||
variant: 'test',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses nested deep links from web URLs', () => {
|
|
||||||
const router = new DeepLinkRouter();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
router.parseDeepLink('https://app.bytelyst.dev/open?dl=myapp%3A%2F%2Fsurvey%3Fid%3Ds1')
|
|
||||||
).toEqual({
|
|
||||||
screen: 'survey',
|
|
||||||
params: { id: 's1' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches to registered handlers and falls back when needed', () => {
|
|
||||||
const router = new DeepLinkRouter();
|
|
||||||
const primaryHandler = vi.fn();
|
|
||||||
const fallbackHandler = vi.fn();
|
|
||||||
|
|
||||||
router.register(DeepLinkScreens.BROADCAST_DETAIL, primaryHandler);
|
|
||||||
router.setFallback(fallbackHandler);
|
|
||||||
|
|
||||||
expect(router.handle({ screen: DeepLinkScreens.BROADCAST_DETAIL, params: { id: 'b1' } })).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(primaryHandler).toHaveBeenCalledWith({
|
|
||||||
screen: DeepLinkScreens.BROADCAST_DETAIL,
|
|
||||||
params: { id: 'b1' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(router.handle({ screen: 'unknown' })).toBe(true);
|
|
||||||
expect(fallbackHandler).toHaveBeenCalledWith({ screen: 'unknown' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false and warns when processing an invalid URL without a fallback', () => {
|
|
||||||
const router = new DeepLinkRouter();
|
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
|
|
||||||
expect(router.process('not-a-url')).toBe(false);
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith('[DeepLink] Failed to parse: not-a-url');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createBroadcastDeepLink', () => {
|
|
||||||
it('builds deep links with params and broadcastId', () => {
|
|
||||||
expect(
|
|
||||||
createBroadcastDeepLink(
|
|
||||||
'https://app.bytelyst.dev',
|
|
||||||
DeepLinkScreens.ANNOUNCEMENTS,
|
|
||||||
{ tab: 'latest' },
|
|
||||||
'b42'
|
|
||||||
)
|
|
||||||
).toBe('https://app.bytelyst.dev/announcements?tab=latest&broadcastId=b42');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
185
vendor/bytelyst/broadcast-client/src/index.ts
vendored
185
vendor/bytelyst/broadcast-client/src/index.ts
vendored
@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* Broadcast Client — Browser/React Native-safe broadcast messaging client
|
|
||||||
* @module @bytelyst/broadcast-client
|
|
||||||
*/
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Types
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface Broadcast {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
bodyMarkdown?: string;
|
|
||||||
ctaText?: string;
|
|
||||||
ctaUrl?: string;
|
|
||||||
imageUrl?: string;
|
|
||||||
channels: ('push' | 'in_app' | 'email')[];
|
|
||||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused';
|
|
||||||
scheduledAt?: string;
|
|
||||||
sentAt?: string;
|
|
||||||
variant?: 'control' | 'treatment';
|
|
||||||
experimentId?: string;
|
|
||||||
parentBroadcastId?: string;
|
|
||||||
metrics: BroadcastMetrics;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
createdBy: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BroadcastMetrics {
|
|
||||||
targetedCount: number;
|
|
||||||
sentCount: number;
|
|
||||||
deliveredCount: number;
|
|
||||||
openedCount: number;
|
|
||||||
clickedCount: number;
|
|
||||||
dismissedCount: number;
|
|
||||||
convertedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InAppMessage {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
productId: string;
|
|
||||||
broadcastId: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
bodyMarkdown?: string;
|
|
||||||
ctaText?: string;
|
|
||||||
ctaUrl?: string;
|
|
||||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
style: 'banner' | 'modal' | 'toast' | 'fullscreen';
|
|
||||||
dismissible: boolean;
|
|
||||||
expiresAt?: string;
|
|
||||||
status: 'unread' | 'read' | 'dismissed';
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BroadcastClientConfig {
|
|
||||||
/** Platform service base URL */
|
|
||||||
baseUrl: string;
|
|
||||||
/** Product ID */
|
|
||||||
productId: string;
|
|
||||||
/** Auth token provider (async or sync) */
|
|
||||||
getAuthToken: (() => string) | (() => Promise<string>);
|
|
||||||
/** Platform identifier */
|
|
||||||
platform: 'web' | 'ios' | 'android' | 'macos' | 'windows';
|
|
||||||
/** App version */
|
|
||||||
appVersion: string;
|
|
||||||
/** OS version */
|
|
||||||
osVersion: string;
|
|
||||||
/** Optional country code */
|
|
||||||
countryCode?: string;
|
|
||||||
/** Optional region code */
|
|
||||||
regionCode?: string;
|
|
||||||
/** User segments (default: ['free']) */
|
|
||||||
userSegments?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Client Factory
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface BroadcastClient {
|
|
||||||
/** List active in-app messages for current user */
|
|
||||||
listMessages(): Promise<{ messages: InAppMessage[] }>;
|
|
||||||
/** Mark message as read */
|
|
||||||
markRead(messageId: string): Promise<void>;
|
|
||||||
/** Mark message as dismissed */
|
|
||||||
markDismissed(messageId: string): Promise<void>;
|
|
||||||
/** Track CTA click */
|
|
||||||
trackClick(messageId: string): Promise<{ redirectUrl?: string }>;
|
|
||||||
/** Poll for new messages (use with setInterval) */
|
|
||||||
pollMessages(intervalMs?: number): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBroadcastClient(config: BroadcastClientConfig): BroadcastClient {
|
|
||||||
const headers = async () => ({
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`,
|
|
||||||
'x-product-id': config.productId,
|
|
||||||
'x-platform': config.platform,
|
|
||||||
'x-app-version': config.appVersion,
|
|
||||||
'x-os-version': config.osVersion,
|
|
||||||
...(config.countryCode && { 'x-country-code': config.countryCode }),
|
|
||||||
...(config.regionCode && { 'x-region-code': config.regionCode }),
|
|
||||||
'x-user-segments': (config.userSegments ?? ['free']).join(','),
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = async <T>(path: string, options?: RequestInit): Promise<T> => {
|
|
||||||
const res = await fetch(`${config.baseUrl}${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
...(await headers()),
|
|
||||||
...(options?.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
throw new Error(`Broadcast API error: ${res.status} ${err}`);
|
|
||||||
}
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async listMessages() {
|
|
||||||
return request<{ messages: InAppMessage[] }>('/broadcasts');
|
|
||||||
},
|
|
||||||
|
|
||||||
async markRead(messageId: string) {
|
|
||||||
await request<void>(`/broadcasts/${messageId}/read`, { method: 'POST' });
|
|
||||||
},
|
|
||||||
|
|
||||||
async markDismissed(messageId: string) {
|
|
||||||
await request<void>(`/broadcasts/${messageId}/dismiss`, { method: 'POST' });
|
|
||||||
},
|
|
||||||
|
|
||||||
async trackClick(messageId: string) {
|
|
||||||
return request<{ redirectUrl?: string }>(`/broadcasts/${messageId}/click`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
pollMessages(intervalMs = 60000) {
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
pollInterval = setInterval(() => {
|
|
||||||
this.listMessages().catch(() => {});
|
|
||||||
}, intervalMs);
|
|
||||||
return () => {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// React Hook (optional)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function createUseBroadcast(client: BroadcastClient) {
|
|
||||||
return function useBroadcast() {
|
|
||||||
return { client };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Deep Link Router
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export {
|
|
||||||
DeepLinkRouter,
|
|
||||||
deepLinkRouter,
|
|
||||||
DeepLinkScreens,
|
|
||||||
createBroadcastDeepLink,
|
|
||||||
type DeepLinkRoute,
|
|
||||||
type DeepLinkHandler,
|
|
||||||
} from './deep-link.js';
|
|
||||||
|
|
||||||
10
vendor/bytelyst/broadcast-client/tsconfig.json
vendored
10
vendor/bytelyst/broadcast-client/tsconfig.json
vendored
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"declaration": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
19
vendor/bytelyst/celebrations/package.json
vendored
19
vendor/bytelyst/celebrations/package.json
vendored
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/celebrations",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
vendor/bytelyst/celebrations/src/index.ts
vendored
25
vendor/bytelyst/celebrations/src/index.ts
vendored
@ -1,25 +0,0 @@
|
|||||||
export interface Celebration {
|
|
||||||
emoji: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CELEBRATION: Celebration = {
|
|
||||||
emoji: '👏',
|
|
||||||
title: 'Great Job!',
|
|
||||||
};
|
|
||||||
|
|
||||||
const BY_TYPE: Record<string, Celebration> = {
|
|
||||||
session_completed: { emoji: '🎉', title: 'Fast Complete!' },
|
|
||||||
task_completed: { emoji: '✅', title: 'Well Done!' },
|
|
||||||
streak_milestone: { emoji: '🔥', title: 'Streak Milestone!' },
|
|
||||||
achievement_unlocked: { emoji: '🏆', title: 'Achievement Unlocked!' },
|
|
||||||
level_up: { emoji: '⬆️', title: 'Level Up!' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createCelebrationEngine() {
|
|
||||||
return {
|
|
||||||
getCelebration(type: string): Celebration {
|
|
||||||
return BY_TYPE[type] ?? DEFAULT_CELEBRATION;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
9
vendor/bytelyst/celebrations/tsconfig.json
vendored
9
vendor/bytelyst/celebrations/tsconfig.json
vendored
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
26
vendor/bytelyst/client-encrypt/package.json
vendored
26
vendor/bytelyst/client-encrypt/package.json
vendored
@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/client-encrypt",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"vitest": "^3.0.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
181
vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts
vendored
181
vendor/bytelyst/client-encrypt/src/aes-gcm.test.ts
vendored
@ -1,181 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
encryptField,
|
|
||||||
decryptField,
|
|
||||||
generateKey,
|
|
||||||
keyFromHex,
|
|
||||||
keyToHex,
|
|
||||||
deriveKey,
|
|
||||||
} from './aes-gcm.js';
|
|
||||||
import { isEncryptedField } from './guards.js';
|
|
||||||
import { toHex, fromHex } from './hex.js';
|
|
||||||
|
|
||||||
describe('encryptField / decryptField', () => {
|
|
||||||
it('roundtrip', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('Hello, World!', key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, key);
|
|
||||||
expect(decrypted).toBe('Hello, World!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty string', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('', key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, key);
|
|
||||||
expect(decrypted).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unicode', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const text = 'こんにちは世界 🌍 مرحبا Ñoño';
|
|
||||||
const encrypted = await encryptField(text, key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, key);
|
|
||||||
expect(decrypted).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('large payload', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const text = 'A'.repeat(100_000);
|
|
||||||
const encrypted = await encryptField(text, key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, key);
|
|
||||||
expect(decrypted).toBe(text);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('EncryptedField structure', () => {
|
|
||||||
it('has correct sentinel fields', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('test', key, 'dek_test');
|
|
||||||
expect(encrypted.__encrypted).toBe(true);
|
|
||||||
expect(encrypted.v).toBe(1);
|
|
||||||
expect(encrypted.alg).toBe('aes-256-gcm');
|
|
||||||
expect(encrypted.dekId).toBe('dek_test');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has correct hex lengths', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('test', key, 'dek_test');
|
|
||||||
expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex
|
|
||||||
expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex
|
|
||||||
expect(encrypted.ct.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unique IVs per encryption', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const a = await encryptField('same', key, 'dek_test');
|
|
||||||
const b = await encryptField('same', key, 'dek_test');
|
|
||||||
expect(a.iv).not.toBe(b.iv);
|
|
||||||
expect(a.ct).not.toBe(b.ct);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AAD (Additional Authenticated Data)', () => {
|
|
||||||
it('roundtrip with AAD', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx');
|
|
||||||
const decrypted = await decryptField(encrypted, key, 'user:ctx');
|
|
||||||
expect(decrypted).toBe('secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong AAD fails', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('secret', key, 'dek_test', 'correct');
|
|
||||||
await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('missing AAD fails', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad');
|
|
||||||
await expect(decryptField(encrypted, key)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('wrong key', () => {
|
|
||||||
it('decrypt with wrong key fails', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const wrongKey = await generateKey();
|
|
||||||
const encrypted = await encryptField('secret', key, 'dek_test');
|
|
||||||
await expect(decryptField(encrypted, wrongKey)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('keyFromHex / keyToHex', () => {
|
|
||||||
it('roundtrip', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const hex = await keyToHex(key);
|
|
||||||
expect(hex.length).toBe(64); // 32 bytes = 64 hex chars
|
|
||||||
const restored = await keyFromHex(hex);
|
|
||||||
const encrypted = await encryptField('test', key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, restored);
|
|
||||||
expect(decrypted).toBe('test');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid length', async () => {
|
|
||||||
await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deriveKey', () => {
|
|
||||||
it('derives consistent key from passphrase + salt', async () => {
|
|
||||||
const salt = new Uint8Array(16);
|
|
||||||
globalThis.crypto.getRandomValues(salt);
|
|
||||||
const key1 = await deriveKey('my-passphrase', salt, 1000, true);
|
|
||||||
const key2 = await deriveKey('my-passphrase', salt, 1000, true);
|
|
||||||
const hex1 = await keyToHex(key1);
|
|
||||||
const hex2 = await keyToHex(key2);
|
|
||||||
expect(hex1).toBe(hex2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('different passphrases produce different keys', async () => {
|
|
||||||
const salt = new Uint8Array(16);
|
|
||||||
globalThis.crypto.getRandomValues(salt);
|
|
||||||
const key1 = await deriveKey('pass-1', salt, 1000, true);
|
|
||||||
const key2 = await deriveKey('pass-2', salt, 1000, true);
|
|
||||||
const hex1 = await keyToHex(key1);
|
|
||||||
const hex2 = await keyToHex(key2);
|
|
||||||
expect(hex1).not.toBe(hex2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derived key can encrypt/decrypt', async () => {
|
|
||||||
const salt = new Uint8Array(16);
|
|
||||||
globalThis.crypto.getRandomValues(salt);
|
|
||||||
const key = await deriveKey('test', salt, 1000, true);
|
|
||||||
const encrypted = await encryptField('hello', key, 'dek_test');
|
|
||||||
const decrypted = await decryptField(encrypted, key);
|
|
||||||
expect(decrypted).toBe('hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isEncryptedField', () => {
|
|
||||||
it('true for valid EncryptedField', async () => {
|
|
||||||
const key = await generateKey();
|
|
||||||
const encrypted = await encryptField('test', key, 'dek_test');
|
|
||||||
expect(isEncryptedField(encrypted)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('false for plain string', () => {
|
|
||||||
expect(isEncryptedField('just a string')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('false for null', () => {
|
|
||||||
expect(isEncryptedField(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('false for incomplete object', () => {
|
|
||||||
expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hex utilities', () => {
|
|
||||||
it('toHex / fromHex roundtrip', () => {
|
|
||||||
const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]);
|
|
||||||
const hex = toHex(bytes);
|
|
||||||
expect(hex).toBe('000fffabcd');
|
|
||||||
const restored = fromHex(hex);
|
|
||||||
expect(restored).toEqual(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fromHex rejects odd length', () => {
|
|
||||||
expect(() => fromHex('a')).toThrow('even length');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
215
vendor/bytelyst/client-encrypt/src/aes-gcm.ts
vendored
215
vendor/bytelyst/client-encrypt/src/aes-gcm.ts
vendored
@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/client-encrypt — AES-256-GCM via Web Crypto API
|
|
||||||
*
|
|
||||||
* Works in browsers (window.crypto.subtle) and React Native (expo-crypto polyfill).
|
|
||||||
* Produces EncryptedField objects wire-compatible with:
|
|
||||||
* - @bytelyst/field-encrypt (Node.js server)
|
|
||||||
* - BLFieldEncrypt (Swift CryptoKit / Kotlin javax.crypto)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EncryptedField } from './types.js';
|
|
||||||
import { toHex, fromHex } from './hex.js';
|
|
||||||
|
|
||||||
const ALGORITHM = 'AES-GCM';
|
|
||||||
const KEY_SIZE_BITS = 256;
|
|
||||||
const IV_BYTES = 12;
|
|
||||||
const TAG_BITS = 128;
|
|
||||||
|
|
||||||
/** Get the SubtleCrypto instance (browser or globalThis). */
|
|
||||||
function getSubtle(): SubtleCrypto {
|
|
||||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
|
|
||||||
return globalThis.crypto.subtle;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
'@bytelyst/client-encrypt requires Web Crypto API (SubtleCrypto). ' +
|
|
||||||
'Use a polyfill in React Native (e.g., expo-crypto).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the crypto object for random bytes. */
|
|
||||||
function getCrypto(): Crypto {
|
|
||||||
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
|
|
||||||
return globalThis.crypto;
|
|
||||||
}
|
|
||||||
throw new Error('@bytelyst/client-encrypt requires globalThis.crypto for random bytes.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a plaintext string with AES-256-GCM using Web Crypto API.
|
|
||||||
*
|
|
||||||
* @param plaintext - UTF-8 string to encrypt
|
|
||||||
* @param key - CryptoKey (AES-GCM, 256-bit)
|
|
||||||
* @param dekId - DEK identifier stored in the output
|
|
||||||
* @param aad - Optional additional authenticated data
|
|
||||||
* @returns EncryptedField with hex-encoded ciphertext, IV, and tag
|
|
||||||
*/
|
|
||||||
export async function encryptField(
|
|
||||||
plaintext: string,
|
|
||||||
key: CryptoKey,
|
|
||||||
dekId: string,
|
|
||||||
aad?: string
|
|
||||||
): Promise<EncryptedField> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const crypto = getCrypto();
|
|
||||||
|
|
||||||
const iv = new Uint8Array(IV_BYTES);
|
|
||||||
crypto.getRandomValues(iv);
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const plaintextBytes = encoder.encode(plaintext);
|
|
||||||
|
|
||||||
const params: AesGcmParams = {
|
|
||||||
name: ALGORITHM,
|
|
||||||
iv: iv.buffer as ArrayBuffer,
|
|
||||||
tagLength: TAG_BITS,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (aad) {
|
|
||||||
params.additionalData = encoder.encode(aad).buffer as ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web Crypto returns ciphertext || tag concatenated
|
|
||||||
const ciphertextWithTag = new Uint8Array(
|
|
||||||
await subtle.encrypt(params, key, plaintextBytes.buffer as ArrayBuffer)
|
|
||||||
);
|
|
||||||
|
|
||||||
const tagOffset = ciphertextWithTag.length - TAG_BITS / 8;
|
|
||||||
const ct = ciphertextWithTag.slice(0, tagOffset);
|
|
||||||
const tag = ciphertextWithTag.slice(tagOffset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
__encrypted: true,
|
|
||||||
v: 1,
|
|
||||||
alg: 'aes-256-gcm',
|
|
||||||
ct: toHex(ct),
|
|
||||||
iv: toHex(iv),
|
|
||||||
tag: toHex(tag),
|
|
||||||
dekId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt an EncryptedField back to plaintext.
|
|
||||||
*
|
|
||||||
* @param field - EncryptedField object
|
|
||||||
* @param key - CryptoKey (must match the key used to encrypt)
|
|
||||||
* @param aad - Optional AAD (must match the AAD used during encryption)
|
|
||||||
* @returns Decrypted UTF-8 string
|
|
||||||
* @throws DOMException if authentication tag verification fails
|
|
||||||
*/
|
|
||||||
export async function decryptField(
|
|
||||||
field: EncryptedField,
|
|
||||||
key: CryptoKey,
|
|
||||||
aad?: string
|
|
||||||
): Promise<string> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
|
|
||||||
const iv = fromHex(field.iv);
|
|
||||||
const ct = fromHex(field.ct);
|
|
||||||
const tag = fromHex(field.tag);
|
|
||||||
|
|
||||||
// Web Crypto expects ciphertext || tag concatenated
|
|
||||||
const ciphertextWithTag = new Uint8Array(ct.length + tag.length);
|
|
||||||
ciphertextWithTag.set(ct, 0);
|
|
||||||
ciphertextWithTag.set(tag, ct.length);
|
|
||||||
|
|
||||||
const params: AesGcmParams = {
|
|
||||||
name: ALGORITHM,
|
|
||||||
iv: iv.buffer as ArrayBuffer,
|
|
||||||
tagLength: TAG_BITS,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (aad) {
|
|
||||||
params.additionalData = new TextEncoder().encode(aad).buffer as ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plaintextBytes = new Uint8Array(
|
|
||||||
await subtle.decrypt(params, key, ciphertextWithTag.buffer as ArrayBuffer)
|
|
||||||
);
|
|
||||||
|
|
||||||
return new TextDecoder().decode(plaintextBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random AES-256-GCM CryptoKey.
|
|
||||||
*
|
|
||||||
* @param extractable - Whether the key material can be exported (default: true).
|
|
||||||
* Set to `false` for non-extractable keys stored in IndexedDB.
|
|
||||||
*/
|
|
||||||
export async function generateKey(extractable = true): Promise<CryptoKey> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
return subtle.generateKey({ name: ALGORITHM, length: KEY_SIZE_BITS }, extractable, [
|
|
||||||
'encrypt',
|
|
||||||
'decrypt',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import a hex-encoded key string as a CryptoKey.
|
|
||||||
*
|
|
||||||
* @param hex - 64 hex chars = 32 bytes
|
|
||||||
* @param extractable - Whether the imported key can be exported (default: true)
|
|
||||||
*/
|
|
||||||
export async function keyFromHex(hex: string, extractable = true): Promise<CryptoKey> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const keyBytes = fromHex(hex);
|
|
||||||
if (keyBytes.length !== KEY_SIZE_BITS / 8) {
|
|
||||||
throw new Error(`AES-256-GCM requires a 32-byte key, got ${keyBytes.length}`);
|
|
||||||
}
|
|
||||||
return subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
keyBytes.buffer as ArrayBuffer,
|
|
||||||
{ name: ALGORITHM, length: KEY_SIZE_BITS },
|
|
||||||
extractable,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export a CryptoKey to a hex-encoded string.
|
|
||||||
* Only works if the key was created with `extractable: true`.
|
|
||||||
*/
|
|
||||||
export async function keyToHex(key: CryptoKey): Promise<string> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const raw = new Uint8Array(await subtle.exportKey('raw', key));
|
|
||||||
return toHex(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive an AES-256 key from a passphrase using PBKDF2.
|
|
||||||
*
|
|
||||||
* @param passphrase - User passphrase
|
|
||||||
* @param salt - Random salt (at least 16 bytes recommended)
|
|
||||||
* @param iterations - PBKDF2 iterations (default: 600,000 per OWASP 2023)
|
|
||||||
* @param extractable - Whether derived key can be exported (default: false)
|
|
||||||
*/
|
|
||||||
export async function deriveKey(
|
|
||||||
passphrase: string,
|
|
||||||
salt: Uint8Array,
|
|
||||||
iterations = 600_000,
|
|
||||||
extractable = false
|
|
||||||
): Promise<CryptoKey> {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
const baseKey = await subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
encoder.encode(passphrase).buffer as ArrayBuffer,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
|
|
||||||
return subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: salt.buffer as ArrayBuffer,
|
|
||||||
iterations,
|
|
||||||
hash: 'SHA-256',
|
|
||||||
},
|
|
||||||
baseKey,
|
|
||||||
{ name: ALGORITHM, length: KEY_SIZE_BITS },
|
|
||||||
extractable,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
22
vendor/bytelyst/client-encrypt/src/guards.ts
vendored
22
vendor/bytelyst/client-encrypt/src/guards.ts
vendored
@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/client-encrypt — Type guards
|
|
||||||
*
|
|
||||||
* Compatible with @bytelyst/field-encrypt isEncryptedField() on the server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { EncryptedField } from './types.js';
|
|
||||||
|
|
||||||
/** Check if a value is an EncryptedField object. */
|
|
||||||
export function isEncryptedField(value: unknown): value is EncryptedField {
|
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
obj.__encrypted === true &&
|
|
||||||
obj.v !== undefined &&
|
|
||||||
obj.alg !== undefined &&
|
|
||||||
typeof obj.ct === 'string' &&
|
|
||||||
typeof obj.iv === 'string' &&
|
|
||||||
typeof obj.tag === 'string' &&
|
|
||||||
typeof obj.dekId === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
28
vendor/bytelyst/client-encrypt/src/hex.ts
vendored
28
vendor/bytelyst/client-encrypt/src/hex.ts
vendored
@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/client-encrypt — Hex encoding utilities
|
|
||||||
*
|
|
||||||
* Converts between Uint8Array and hex strings.
|
|
||||||
* Compatible with the hex encoding used by @bytelyst/field-encrypt (Node.js)
|
|
||||||
* and BLFieldEncrypt (Swift/Kotlin).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Encode a Uint8Array to a lowercase hex string. */
|
|
||||||
export function toHex(bytes: Uint8Array): string {
|
|
||||||
const parts: string[] = new Array(bytes.length);
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
parts[i] = bytes[i].toString(16).padStart(2, '0');
|
|
||||||
}
|
|
||||||
return parts.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode a hex string to a Uint8Array. */
|
|
||||||
export function fromHex(hex: string): Uint8Array {
|
|
||||||
if (hex.length % 2 !== 0) {
|
|
||||||
throw new Error(`Hex string must have even length, got ${hex.length}`);
|
|
||||||
}
|
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
36
vendor/bytelyst/client-encrypt/src/index.ts
vendored
36
vendor/bytelyst/client-encrypt/src/index.ts
vendored
@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/client-encrypt
|
|
||||||
*
|
|
||||||
* Client-side AES-256-GCM field encryption using Web Crypto API.
|
|
||||||
* Works in browsers and React Native (with SubtleCrypto polyfill).
|
|
||||||
* Wire-compatible with @bytelyst/field-encrypt (server) and
|
|
||||||
* BLFieldEncrypt (Swift/Kotlin native SDKs).
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* import { generateKey, encryptField, decryptField } from '@bytelyst/client-encrypt';
|
|
||||||
*
|
|
||||||
* const key = await generateKey();
|
|
||||||
* const encrypted = await encryptField('sensitive data', key, 'dek_user1_notes');
|
|
||||||
* const plaintext = await decryptField(encrypted, key);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Main API ────────────────────────────────────────
|
|
||||||
export {
|
|
||||||
encryptField,
|
|
||||||
decryptField,
|
|
||||||
generateKey,
|
|
||||||
keyFromHex,
|
|
||||||
keyToHex,
|
|
||||||
deriveKey,
|
|
||||||
} from './aes-gcm.js';
|
|
||||||
|
|
||||||
// ── Type guards ─────────────────────────────────────
|
|
||||||
export { isEncryptedField } from './guards.js';
|
|
||||||
|
|
||||||
// ── Hex utilities ───────────────────────────────────
|
|
||||||
export { toHex, fromHex } from './hex.js';
|
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────
|
|
||||||
export type { EncryptedField, ClientEncryptContext } from './types.js';
|
|
||||||
33
vendor/bytelyst/client-encrypt/src/types.ts
vendored
33
vendor/bytelyst/client-encrypt/src/types.ts
vendored
@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* @bytelyst/client-encrypt — Types
|
|
||||||
*
|
|
||||||
* Shared type definitions for client-side field encryption.
|
|
||||||
* Wire-compatible with @bytelyst/field-encrypt (server) and
|
|
||||||
* BLFieldEncrypt (Swift/Kotlin native SDKs).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Encrypted field stored in Cosmos DB or API responses. */
|
|
||||||
export interface EncryptedField {
|
|
||||||
/** Sentinel — always true for encrypted fields. */
|
|
||||||
readonly __encrypted: true;
|
|
||||||
/** Schema version for future algorithm changes. */
|
|
||||||
readonly v: 1;
|
|
||||||
/** Algorithm identifier. */
|
|
||||||
readonly alg: 'aes-256-gcm';
|
|
||||||
/** Ciphertext (hex-encoded). */
|
|
||||||
readonly ct: string;
|
|
||||||
/** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */
|
|
||||||
readonly iv: string;
|
|
||||||
/** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */
|
|
||||||
readonly tag: string;
|
|
||||||
/** DEK identifier — identifies which key to use for decryption. */
|
|
||||||
readonly dekId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for encrypt/decrypt operations. */
|
|
||||||
export interface ClientEncryptContext {
|
|
||||||
/** Scope for DEK isolation (typically userId). */
|
|
||||||
readonly userId: string;
|
|
||||||
/** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */
|
|
||||||
readonly context: string;
|
|
||||||
}
|
|
||||||
10
vendor/bytelyst/client-encrypt/tsconfig.json
vendored
10
vendor/bytelyst/client-encrypt/tsconfig.json
vendored
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"lib": ["ES2022", "DOM"]
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
48
vendor/bytelyst/config/package.json
vendored
48
vendor/bytelyst/config/package.json
vendored
@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@bytelyst/config",
|
|
||||||
"version": "0.1.5",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"./keyvault": {
|
|
||||||
"import": "./dist/keyvault.js",
|
|
||||||
"types": "./dist/keyvault.d.ts"
|
|
||||||
},
|
|
||||||
"./product-identity": {
|
|
||||||
"import": "./dist/product-identity.js",
|
|
||||||
"types": "./dist/product-identity.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "vitest run --pool forks"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@azure/identity": ">=4.0.0",
|
|
||||||
"@azure/keyvault-secrets": ">=4.8.0",
|
|
||||||
"zod": ">=3.20.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@azure/identity": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@azure/keyvault-secrets": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@azure/identity": "^4.13.0",
|
|
||||||
"@azure/keyvault-secrets": "^4.10.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
167
vendor/bytelyst/config/src/__tests__/config.test.ts
vendored
167
vendor/bytelyst/config/src/__tests__/config.test.ts
vendored
@ -1,167 +0,0 @@
|
|||||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
baseEnvSchema,
|
|
||||||
loadConfig,
|
|
||||||
loadProductIdentity,
|
|
||||||
getProductId,
|
|
||||||
_resetProductIdentity,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
describe('baseEnvSchema', () => {
|
|
||||||
it('provides defaults for PORT, HOST, NODE_ENV, COSMOS_DATABASE', () => {
|
|
||||||
const result = baseEnvSchema.parse({
|
|
||||||
SERVICE_NAME: 'test-svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
});
|
|
||||||
expect(result.PORT).toBe(3000);
|
|
||||||
expect(result.HOST).toBe('0.0.0.0');
|
|
||||||
expect(result.NODE_ENV).toBe('development');
|
|
||||||
expect(result.COSMOS_DATABASE).toBe('lysnrai');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing SERVICE_NAME', () => {
|
|
||||||
expect(() =>
|
|
||||||
baseEnvSchema.parse({
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing COSMOS_ENDPOINT', () => {
|
|
||||||
expect(() =>
|
|
||||||
baseEnvSchema.parse({
|
|
||||||
SERVICE_NAME: 'svc',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing COSMOS_KEY', () => {
|
|
||||||
expect(() =>
|
|
||||||
baseEnvSchema.parse({
|
|
||||||
SERVICE_NAME: 'svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coerces PORT from string', () => {
|
|
||||||
const result = baseEnvSchema.parse({
|
|
||||||
PORT: '4003',
|
|
||||||
SERVICE_NAME: 'svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
});
|
|
||||||
expect(result.PORT).toBe(4003);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts valid NODE_ENV values', () => {
|
|
||||||
for (const env of ['development', 'production', 'test']) {
|
|
||||||
const result = baseEnvSchema.parse({
|
|
||||||
NODE_ENV: env,
|
|
||||||
SERVICE_NAME: 'svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
});
|
|
||||||
expect(result.NODE_ENV).toBe(env);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid NODE_ENV', () => {
|
|
||||||
expect(() =>
|
|
||||||
baseEnvSchema.parse({
|
|
||||||
NODE_ENV: 'staging',
|
|
||||||
SERVICE_NAME: 'svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadConfig', () => {
|
|
||||||
const origEnv = process.env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = {
|
|
||||||
...origEnv,
|
|
||||||
SERVICE_NAME: 'test-svc',
|
|
||||||
COSMOS_ENDPOINT: 'https://test.cosmos.azure.com:443/',
|
|
||||||
COSMOS_KEY: 'key==',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = origEnv;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses base env without extension', () => {
|
|
||||||
const config = loadConfig();
|
|
||||||
expect(config.SERVICE_NAME).toBe('test-svc');
|
|
||||||
expect(config.PORT).toBe(3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extends with additional fields', async () => {
|
|
||||||
process.env.STRIPE_KEY = 'sk_test_123';
|
|
||||||
const { z } = await import('zod');
|
|
||||||
const config = loadConfig({ STRIPE_KEY: z.string().min(1) });
|
|
||||||
expect(config.STRIPE_KEY).toBe('sk_test_123');
|
|
||||||
expect(config.SERVICE_NAME).toBe('test-svc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on missing required extension field', async () => {
|
|
||||||
const { z } = await import('zod');
|
|
||||||
expect(() => loadConfig({ MISSING_FIELD: z.string().min(1) })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('productIdentity', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
_resetProductIdentity();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to env vars', () => {
|
|
||||||
process.env.PRODUCT_ID = 'testprod';
|
|
||||||
process.env.DISPLAY_NAME = 'TestProd';
|
|
||||||
const identity = loadProductIdentity();
|
|
||||||
expect(identity.productId).toBe('testprod');
|
|
||||||
expect(identity.displayName).toBe('TestProd');
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
delete process.env.DISPLAY_NAME;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to lysnrai when no env or file', () => {
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
const identity = loadProductIdentity();
|
|
||||||
expect(identity.productId).toBe('lysnrai');
|
|
||||||
expect(identity.displayName).toBe('LysnrAI');
|
|
||||||
expect(identity.licensePrefix).toBe('LYSNR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getProductId returns just the ID', () => {
|
|
||||||
_resetProductIdentity();
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
expect(getProductId()).toBe('lysnrai');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caches identity after first load', () => {
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
const id1 = loadProductIdentity();
|
|
||||||
process.env.PRODUCT_ID = 'changed';
|
|
||||||
const id2 = loadProductIdentity();
|
|
||||||
expect(id1).toBe(id2); // same cached object
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('_resetProductIdentity clears cache', () => {
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
loadProductIdentity();
|
|
||||||
_resetProductIdentity();
|
|
||||||
process.env.PRODUCT_ID = 'newprod';
|
|
||||||
const fresh = loadProductIdentity();
|
|
||||||
expect(fresh.productId).toBe('newprod');
|
|
||||||
delete process.env.PRODUCT_ID;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for Azure Key Vault secret resolution.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { resolveKeyVaultSecrets, LYSNR_SECRETS } from '../keyvault.js';
|
|
||||||
import type { SecretMapping } from '../keyvault.js';
|
|
||||||
|
|
||||||
// Mock Azure SDK dynamic imports to prevent test timeouts
|
|
||||||
const { mockGetSecret } = vi.hoisted(() => {
|
|
||||||
const mockGetSecret = vi.fn();
|
|
||||||
return { mockGetSecret };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('@azure/identity', () => ({
|
|
||||||
DefaultAzureCredential: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@azure/keyvault-secrets', () => ({
|
|
||||||
SecretClient: vi.fn().mockImplementation(() => ({
|
|
||||||
getSecret: mockGetSecret,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('resolveKeyVaultSecrets', () => {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Clean env vars used in tests
|
|
||||||
delete process.env.AZURE_KEYVAULT_URL;
|
|
||||||
delete process.env.TEST_SECRET_A;
|
|
||||||
delete process.env.TEST_SECRET_B;
|
|
||||||
mockGetSecret.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env = { ...originalEnv };
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips entirely when AZURE_KEYVAULT_URL is not set', async () => {
|
|
||||||
const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }];
|
|
||||||
|
|
||||||
await resolveKeyVaultSecrets(secrets);
|
|
||||||
|
|
||||||
// Should not have set the env var (no KV to resolve from)
|
|
||||||
expect(process.env.TEST_SECRET_A).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips secrets that already exist in env', async () => {
|
|
||||||
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
|
|
||||||
process.env.TEST_SECRET_A = 'already-set';
|
|
||||||
|
|
||||||
const secrets: SecretMapping[] = [{ kvName: 'test-secret-a', envVar: 'TEST_SECRET_A' }];
|
|
||||||
|
|
||||||
// Should not attempt KV call since all secrets are present
|
|
||||||
await resolveKeyVaultSecrets(secrets);
|
|
||||||
|
|
||||||
expect(process.env.TEST_SECRET_A).toBe('already-set');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts custom vaultUrl via opts', async () => {
|
|
||||||
mockGetSecret.mockResolvedValue({ value: 'resolved-value' });
|
|
||||||
|
|
||||||
const secrets: SecretMapping[] = [{ kvName: 'test-secret', envVar: 'TEST_SECRET_A' }];
|
|
||||||
|
|
||||||
await resolveKeyVaultSecrets(secrets, { vaultUrl: 'https://kv-test.vault.azure.net' });
|
|
||||||
|
|
||||||
expect(process.env.TEST_SECRET_A).toBe('resolved-value');
|
|
||||||
expect(mockGetSecret).toHaveBeenCalledWith('test-secret');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty secrets array', async () => {
|
|
||||||
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
|
|
||||||
|
|
||||||
await expect(resolveKeyVaultSecrets([])).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves multiple missing secrets from Key Vault', async () => {
|
|
||||||
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
|
|
||||||
mockGetSecret
|
|
||||||
.mockResolvedValueOnce({ value: 'secret-a-val' })
|
|
||||||
.mockResolvedValueOnce({ value: 'secret-b-val' });
|
|
||||||
|
|
||||||
const secrets: SecretMapping[] = [
|
|
||||||
{ kvName: 'secret-a', envVar: 'TEST_SECRET_A' },
|
|
||||||
{ kvName: 'secret-b', envVar: 'TEST_SECRET_B' },
|
|
||||||
];
|
|
||||||
|
|
||||||
await resolveKeyVaultSecrets(secrets);
|
|
||||||
|
|
||||||
expect(process.env.TEST_SECRET_A).toBe('secret-a-val');
|
|
||||||
expect(process.env.TEST_SECRET_B).toBe('secret-b-val');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('warns but does not throw when getSecret fails', async () => {
|
|
||||||
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
|
|
||||||
mockGetSecret.mockRejectedValue(new Error('SecretNotFound'));
|
|
||||||
|
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const secrets: SecretMapping[] = [{ kvName: 'bad-secret', envVar: 'TEST_SECRET_A' }];
|
|
||||||
|
|
||||||
await resolveKeyVaultSecrets(secrets);
|
|
||||||
|
|
||||||
expect(process.env.TEST_SECRET_A).toBeUndefined();
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1/1 secrets failed'));
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters to only missing secrets — skips already-present', async () => {
|
|
||||||
process.env.AZURE_KEYVAULT_URL = 'https://kv-test.vault.azure.net';
|
|
||||||
process.env.TEST_SECRET_A = 'present';
|
|
||||||
mockGetSecret.mockResolvedValue({ value: 'from-kv' });
|
|
||||||
|
|
||||||
const secrets: SecretMapping[] = [
|
|
||||||
{ kvName: 'secret-a', envVar: 'TEST_SECRET_A' },
|
|
||||||
{ kvName: 'secret-b', envVar: 'TEST_SECRET_B' },
|
|
||||||
];
|
|
||||||
|
|
||||||
await resolveKeyVaultSecrets(secrets);
|
|
||||||
|
|
||||||
// TEST_SECRET_A should remain unchanged (already present)
|
|
||||||
expect(process.env.TEST_SECRET_A).toBe('present');
|
|
||||||
// TEST_SECRET_B should be resolved from KV
|
|
||||||
expect(process.env.TEST_SECRET_B).toBe('from-kv');
|
|
||||||
// getSecret should only be called for the missing secret
|
|
||||||
expect(mockGetSecret).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockGetSecret).toHaveBeenCalledWith('secret-b');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('LYSNR_SECRETS', () => {
|
|
||||||
it('exports all expected secret mappings', () => {
|
|
||||||
const expectedKeys = [
|
|
||||||
'COSMOS_KEY',
|
|
||||||
'COSMOS_ENDPOINT',
|
|
||||||
'JWT_SECRET',
|
|
||||||
'STRIPE_SECRET_KEY',
|
|
||||||
'STRIPE_WEBHOOK_SECRET',
|
|
||||||
'BILLING_INTERNAL_KEY',
|
|
||||||
'AZURE_BLOB_CONNECTION_STRING',
|
|
||||||
'AZURE_BLOB_ACCOUNT_KEY',
|
|
||||||
'GEMINI_API_KEY',
|
|
||||||
'SEED_SECRET',
|
|
||||||
'AZURE_SPEECH_KEY',
|
|
||||||
'AZURE_OPENAI_KEY',
|
|
||||||
'AZURE_OPENAI_ENDPOINT',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of expectedKeys) {
|
|
||||||
expect(LYSNR_SECRETS).toHaveProperty(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('each mapping has kvName and envVar', () => {
|
|
||||||
for (const [key, mapping] of Object.entries(LYSNR_SECRETS)) {
|
|
||||||
expect(mapping.kvName).toBeDefined();
|
|
||||||
expect(typeof mapping.kvName).toBe('string');
|
|
||||||
expect(mapping.kvName.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
expect(mapping.envVar).toBeDefined();
|
|
||||||
expect(typeof mapping.envVar).toBe('string');
|
|
||||||
expect(mapping.envVar).toBe(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('kvNames follow lysnr-* naming convention', () => {
|
|
||||||
for (const mapping of Object.values(LYSNR_SECRETS)) {
|
|
||||||
expect(mapping.kvName).toMatch(/^lysnr-/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('envVars are UPPER_SNAKE_CASE', () => {
|
|
||||||
for (const mapping of Object.values(LYSNR_SECRETS)) {
|
|
||||||
expect(mapping.envVar).toMatch(/^[A-Z][A-Z0-9_]*$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,467 +0,0 @@
|
|||||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import {
|
|
||||||
ProductManifestSchema,
|
|
||||||
ExtendedProductManifestSchema,
|
|
||||||
PlatformSchema,
|
|
||||||
ThemeSchema,
|
|
||||||
ContainerDefSchema,
|
|
||||||
FeatureFlagSchema,
|
|
||||||
BundleIdSchema,
|
|
||||||
DEFAULT_THEME,
|
|
||||||
loadProductManifest,
|
|
||||||
loadProductManifestSync,
|
|
||||||
resolveTheme,
|
|
||||||
validateProductManifest,
|
|
||||||
safeValidateProductManifest,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
// ── Minimal valid manifest ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MINIMAL = {
|
|
||||||
productId: 'testprod',
|
|
||||||
displayName: 'TestProd',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Full manifest (matches FlowMonk-style) ──────────────────────────────────
|
|
||||||
|
|
||||||
const FULL = {
|
|
||||||
productId: 'flowmonk',
|
|
||||||
displayName: 'FlowMonk',
|
|
||||||
name: 'FlowMonk',
|
|
||||||
tagline: 'Agent-first planning and execution',
|
|
||||||
domain: 'flowmonk.app',
|
|
||||||
backendPort: 4017,
|
|
||||||
primarySurface: 'web',
|
|
||||||
mobileCompanion: true,
|
|
||||||
platforms: ['web', 'ios', 'android'],
|
|
||||||
bundleIds: {
|
|
||||||
ios: 'com.saravana.flowmonk',
|
|
||||||
android: 'com.saravana.flowmonk',
|
|
||||||
web: 'flowmonk.app',
|
|
||||||
},
|
|
||||||
appStore: {
|
|
||||||
category: 'Productivity',
|
|
||||||
subcategory: 'Task Management',
|
|
||||||
ageRating: '4+',
|
|
||||||
privacyUrl: 'https://flowmonk.app/privacy',
|
|
||||||
termsUrl: 'https://flowmonk.app/terms',
|
|
||||||
supportUrl: 'https://flowmonk.app/support',
|
|
||||||
},
|
|
||||||
cosmos: {
|
|
||||||
containers: [
|
|
||||||
{ name: 'zones', partitionKey: '/userId' },
|
|
||||||
{ name: 'flows', partitionKey: '/userId' },
|
|
||||||
{ name: 'tasks', partitionKey: '/userId' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }],
|
|
||||||
ports: { service: 4017 },
|
|
||||||
version: '0.1.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('ProductManifestSchema', () => {
|
|
||||||
it('parses a minimal manifest (just productId + displayName)', () => {
|
|
||||||
const result = ProductManifestSchema.parse(MINIMAL);
|
|
||||||
expect(result.productId).toBe('testprod');
|
|
||||||
expect(result.displayName).toBe('TestProd');
|
|
||||||
expect(result.platforms).toEqual(['web']); // default
|
|
||||||
expect(result.flags).toEqual([]); // default
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses a full manifest', () => {
|
|
||||||
const result = ProductManifestSchema.parse(FULL);
|
|
||||||
expect(result.productId).toBe('flowmonk');
|
|
||||||
expect(result.backendPort).toBe(4017);
|
|
||||||
expect(result.cosmos?.containers).toHaveLength(3);
|
|
||||||
expect(result.flags).toHaveLength(1);
|
|
||||||
expect(result.appStore?.category).toBe('Productivity');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing productId', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing displayName', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid productId (uppercase)', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow(
|
|
||||||
/lowercase/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects productId starting with number', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows hyphens in productId', () => {
|
|
||||||
const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' });
|
|
||||||
expect(result.productId).toBe('my-app');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects backendPort below 1024', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects backendPort above 65535', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts valid backendPort', () => {
|
|
||||||
const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 });
|
|
||||||
expect(result.backendPort).toBe(4016);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses LysnrAI-style manifest (legacy fields)', () => {
|
|
||||||
const lysnrai = {
|
|
||||||
displayName: 'LysnrAI',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
licensePrefix: 'LYSNR',
|
|
||||||
configDirName: '.LysnrAI',
|
|
||||||
envVarPrefix: 'LYSNR',
|
|
||||||
bundleIdSuffix: 'LysnrAI',
|
|
||||||
packageName: 'lysnrai',
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(lysnrai);
|
|
||||||
expect(result.licensePrefix).toBe('LYSNR');
|
|
||||||
expect(result.envVarPrefix).toBe('LYSNR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses NoteLett-style manifest (bundleId as object)', () => {
|
|
||||||
const notelett = {
|
|
||||||
productId: 'notelett',
|
|
||||||
displayName: 'NoteLett',
|
|
||||||
bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' },
|
|
||||||
backendPort: 4016,
|
|
||||||
appGroup: 'group.com.bytelyst.notelett',
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(notelett);
|
|
||||||
expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' });
|
|
||||||
expect(result.appGroup).toBe('group.com.bytelyst.notelett');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses NomGap-style manifest (bundleId as string)', () => {
|
|
||||||
const nomgap = {
|
|
||||||
productId: 'nomgap',
|
|
||||||
displayName: 'NomGap',
|
|
||||||
bundleId: 'com.saravana.nomgap',
|
|
||||||
domain: 'nomgap.app',
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(nomgap);
|
|
||||||
expect(result.bundleId).toBe('com.saravana.nomgap');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses ActionTrail-style manifest (no mobile)', () => {
|
|
||||||
const actiontrail = {
|
|
||||||
productId: 'actiontrail',
|
|
||||||
displayName: 'ActionTrail',
|
|
||||||
tagline: 'AI Activity Oversight',
|
|
||||||
domain: 'actiontrail.dev',
|
|
||||||
backendPort: 4018,
|
|
||||||
primarySurface: 'web',
|
|
||||||
mobileCompanion: false,
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(actiontrail);
|
|
||||||
expect(result.mobileCompanion).toBe(false);
|
|
||||||
expect(result.primarySurface).toBe('web');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('duplicate container validation', () => {
|
|
||||||
it('rejects duplicate container names', () => {
|
|
||||||
const manifest = {
|
|
||||||
...MINIMAL,
|
|
||||||
cosmos: {
|
|
||||||
containers: [
|
|
||||||
{ name: 'users', partitionKey: '/userId' },
|
|
||||||
{ name: 'users', partitionKey: '/email' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows unique container names', () => {
|
|
||||||
const manifest = {
|
|
||||||
...MINIMAL,
|
|
||||||
cosmos: {
|
|
||||||
containers: [
|
|
||||||
{ name: 'users', partitionKey: '/userId' },
|
|
||||||
{ name: 'sessions', partitionKey: '/userId' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(manifest);
|
|
||||||
expect(result.cosmos?.containers).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows single container (no duplicate check needed)', () => {
|
|
||||||
const manifest = {
|
|
||||||
...MINIMAL,
|
|
||||||
cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] },
|
|
||||||
};
|
|
||||||
const result = ProductManifestSchema.parse(manifest);
|
|
||||||
expect(result.cosmos?.containers).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ExtendedProductManifestSchema', () => {
|
|
||||||
it('allows unknown keys (passthrough)', () => {
|
|
||||||
const result = ExtendedProductManifestSchema.parse({
|
|
||||||
...MINIMAL,
|
|
||||||
customField: 'hello',
|
|
||||||
nestedCustom: { x: 1 },
|
|
||||||
});
|
|
||||||
expect((result as Record<string, unknown>).customField).toBe('hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PlatformSchema', () => {
|
|
||||||
it('accepts valid platforms', () => {
|
|
||||||
for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) {
|
|
||||||
expect(PlatformSchema.parse(p)).toBe(p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid platform', () => {
|
|
||||||
expect(() => PlatformSchema.parse('windows')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ThemeSchema', () => {
|
|
||||||
it('validates hex colors', () => {
|
|
||||||
const result = ThemeSchema.parse(DEFAULT_THEME);
|
|
||||||
expect(result.primary).toBe('#5AE68C');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-hex colors', () => {
|
|
||||||
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects 3-digit hex', () => {
|
|
||||||
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ContainerDefSchema', () => {
|
|
||||||
it('parses valid container', () => {
|
|
||||||
const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' });
|
|
||||||
expect(result.name).toBe('users');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts optional ttlSeconds and uniqueKeys', () => {
|
|
||||||
const result = ContainerDefSchema.parse({
|
|
||||||
name: 'sessions',
|
|
||||||
partitionKey: '/userId',
|
|
||||||
ttlSeconds: 86400,
|
|
||||||
uniqueKeys: ['/email'],
|
|
||||||
});
|
|
||||||
expect(result.ttlSeconds).toBe(86400);
|
|
||||||
expect(result.uniqueKeys).toEqual(['/email']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty name', () => {
|
|
||||||
expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects negative ttlSeconds', () => {
|
|
||||||
expect(() =>
|
|
||||||
ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 })
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FeatureFlagSchema', () => {
|
|
||||||
it('accepts boolean default', () => {
|
|
||||||
const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false });
|
|
||||||
expect(result.defaultValue).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts string default', () => {
|
|
||||||
const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' });
|
|
||||||
expect(result.defaultValue).toBe('free');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts number default', () => {
|
|
||||||
const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 });
|
|
||||||
expect(result.defaultValue).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty key', () => {
|
|
||||||
expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BundleIdSchema', () => {
|
|
||||||
it('accepts string bundle ID', () => {
|
|
||||||
expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts per-platform bundle IDs', () => {
|
|
||||||
const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' });
|
|
||||||
expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects empty string', () => {
|
|
||||||
expect(() => BundleIdSchema.parse('')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveTheme', () => {
|
|
||||||
it('returns defaults when no theme specified', () => {
|
|
||||||
const manifest = ProductManifestSchema.parse(MINIMAL);
|
|
||||||
const theme = resolveTheme(manifest);
|
|
||||||
expect(theme).toEqual(DEFAULT_THEME);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges partial theme with defaults', () => {
|
|
||||||
const manifest = ProductManifestSchema.parse({
|
|
||||||
...MINIMAL,
|
|
||||||
theme: { primary: '#FF0000' },
|
|
||||||
});
|
|
||||||
const theme = resolveTheme(manifest);
|
|
||||||
expect(theme.primary).toBe('#FF0000');
|
|
||||||
expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateProductManifest', () => {
|
|
||||||
it('validates a valid object', () => {
|
|
||||||
const result = validateProductManifest(MINIMAL);
|
|
||||||
expect(result.productId).toBe('testprod');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on invalid object', () => {
|
|
||||||
expect(() => validateProductManifest({ bad: true })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('safeValidateProductManifest', () => {
|
|
||||||
it('returns manifest on valid input', () => {
|
|
||||||
const result = safeValidateProductManifest(MINIMAL);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result!.productId).toBe('testprod');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null on invalid input', () => {
|
|
||||||
const result = safeValidateProductManifest({ bad: true });
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadProductManifest / loadProductManifestSync', () => {
|
|
||||||
const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`);
|
|
||||||
const validPath = join(tmpDir, 'valid.json');
|
|
||||||
const invalidPath = join(tmpDir, 'invalid.json');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mkdirSync(tmpDir, { recursive: true });
|
|
||||||
writeFileSync(validPath, JSON.stringify(FULL));
|
|
||||||
writeFileSync(invalidPath, JSON.stringify({ bad: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads and validates from file (async)', async () => {
|
|
||||||
const result = await loadProductManifest(validPath);
|
|
||||||
expect(result.productId).toBe('flowmonk');
|
|
||||||
expect(result.cosmos?.containers).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads and validates from file (sync)', () => {
|
|
||||||
const result = loadProductManifestSync(validPath);
|
|
||||||
expect(result.productId).toBe('flowmonk');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on invalid file (async)', async () => {
|
|
||||||
await expect(loadProductManifest(invalidPath)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on invalid file (sync)', () => {
|
|
||||||
expect(() => loadProductManifestSync(invalidPath)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on missing file (async)', async () => {
|
|
||||||
await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on missing file (sync)', () => {
|
|
||||||
expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('real-world product.json files parse cleanly', () => {
|
|
||||||
it('parses LysnrAI product.json', () => {
|
|
||||||
const json = {
|
|
||||||
displayName: 'LysnrAI',
|
|
||||||
productId: 'lysnrai',
|
|
||||||
licensePrefix: 'LYSNR',
|
|
||||||
configDirName: '.LysnrAI',
|
|
||||||
envVarPrefix: 'LYSNR',
|
|
||||||
bundleIdSuffix: 'LysnrAI',
|
|
||||||
packageName: 'lysnrai',
|
|
||||||
};
|
|
||||||
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses NomGap product.json', () => {
|
|
||||||
const json = {
|
|
||||||
productId: 'nomgap',
|
|
||||||
displayName: 'NomGap',
|
|
||||||
bundleId: 'com.saravana.nomgap',
|
|
||||||
domain: 'nomgap.app',
|
|
||||||
};
|
|
||||||
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses NoteLett product.json', () => {
|
|
||||||
const json = {
|
|
||||||
productId: 'notelett',
|
|
||||||
displayName: 'NoteLett',
|
|
||||||
licensePrefix: 'NOTELETT',
|
|
||||||
configDirName: '.NoteLett',
|
|
||||||
envVarPrefix: 'NOTELETT',
|
|
||||||
bundleIdSuffix: 'notelett',
|
|
||||||
packageName: 'notelett',
|
|
||||||
domain: 'notelett.app',
|
|
||||||
bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' },
|
|
||||||
appGroup: 'group.com.bytelyst.notelett',
|
|
||||||
backendPort: 4016,
|
|
||||||
};
|
|
||||||
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses FlowMonk product.json', () => {
|
|
||||||
expect(() => ProductManifestSchema.parse(FULL)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses ActionTrail product.json', () => {
|
|
||||||
const json = {
|
|
||||||
productId: 'actiontrail',
|
|
||||||
displayName: 'ActionTrail',
|
|
||||||
tagline: 'AI Activity Oversight',
|
|
||||||
domain: 'actiontrail.dev',
|
|
||||||
backendPort: 4018,
|
|
||||||
primarySurface: 'web',
|
|
||||||
mobileCompanion: false,
|
|
||||||
bundleIds: { web: 'actiontrail.dev' },
|
|
||||||
appStore: {
|
|
||||||
category: 'Developer Tools',
|
|
||||||
subcategory: 'AI Monitoring',
|
|
||||||
privacyUrl: 'https://actiontrail.dev/privacy',
|
|
||||||
termsUrl: 'https://actiontrail.dev/terms',
|
|
||||||
supportUrl: 'https://actiontrail.dev/support',
|
|
||||||
},
|
|
||||||
version: '0.1.0',
|
|
||||||
};
|
|
||||||
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
19
vendor/bytelyst/config/src/base-schema.ts
vendored
19
vendor/bytelyst/config/src/base-schema.ts
vendored
@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Base environment schema shared by all Fastify microservices.
|
|
||||||
* Each service extends this with its own fields via loadConfig().
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const baseEnvSchema = z.object({
|
|
||||||
PORT: z.coerce.number().default(3000),
|
|
||||||
HOST: z.string().default('0.0.0.0'),
|
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
||||||
CORS_ORIGIN: z.string().optional(),
|
|
||||||
SERVICE_NAME: z.string(),
|
|
||||||
COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'),
|
|
||||||
COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'),
|
|
||||||
COSMOS_DATABASE: z.string().default('lysnrai'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type BaseEnv = z.infer<typeof baseEnvSchema>;
|
|
||||||
38
vendor/bytelyst/config/src/index.ts
vendored
38
vendor/bytelyst/config/src/index.ts
vendored
@ -1,38 +0,0 @@
|
|||||||
export { baseEnvSchema, type BaseEnv } from './base-schema.js';
|
|
||||||
export { loadConfig } from './loader.js';
|
|
||||||
export {
|
|
||||||
loadProductIdentity,
|
|
||||||
getProductId,
|
|
||||||
_resetProductIdentity,
|
|
||||||
type ProductIdentity,
|
|
||||||
} from './product-identity.js';
|
|
||||||
export {
|
|
||||||
resolveSecrets,
|
|
||||||
resolveKeyVaultSecrets,
|
|
||||||
LYSNR_SECRETS,
|
|
||||||
type SecretMapping,
|
|
||||||
type SecretsProviderType,
|
|
||||||
} from './keyvault.js';
|
|
||||||
export {
|
|
||||||
ProductManifestSchema,
|
|
||||||
PlatformSchema,
|
|
||||||
ThemeSchema,
|
|
||||||
ContainerDefSchema,
|
|
||||||
FeatureFlagSchema,
|
|
||||||
PortConfigSchema,
|
|
||||||
BundleIdSchema,
|
|
||||||
AppStoreSchema,
|
|
||||||
ExtendedProductManifestSchema,
|
|
||||||
DEFAULT_THEME,
|
|
||||||
loadProductManifest,
|
|
||||||
loadProductManifestSync,
|
|
||||||
resolveTheme,
|
|
||||||
validateProductManifest,
|
|
||||||
safeValidateProductManifest,
|
|
||||||
type ProductManifest,
|
|
||||||
type Platform,
|
|
||||||
type Theme,
|
|
||||||
type ContainerDef,
|
|
||||||
type FeatureFlag,
|
|
||||||
type PortConfig,
|
|
||||||
} from './product-manifest.js';
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user