style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -1,42 +1,42 @@
{
"name": "@mana-core/nestjs-integration",
"version": "1.0.0",
"description": "NestJS integration package for Mana Core authentication and credits",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"@nestjs/axios": "^3.0.0 || ^4.0.0",
"axios": "^1.6.0",
"jsonwebtoken": "^9.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
},
"files": [
"dist"
]
"name": "@mana-core/nestjs-integration",
"version": "1.0.0",
"description": "NestJS integration package for Mana Core authentication and credits",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist"
},
"dependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"@nestjs/axios": "^3.0.0 || ^4.0.0",
"axios": "^1.6.0",
"jsonwebtoken": "^9.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
},
"files": [
"dist"
]
}

View file

@ -1,23 +1,23 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface JwtPayload {
sub: string;
email: string;
role?: string;
app_id?: string;
iat?: number;
exp?: number;
sub: string;
email: string;
role?: string;
app_id?: string;
iat?: number;
exp?: number;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
(data: keyof JwtPayload | undefined, ctx: ExecutionContext): JwtPayload | string => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (data) {
return user[data] as string;
}
if (data) {
return user[data] as string;
}
return user;
},
return user;
}
);

View file

@ -1,22 +1,22 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export interface InsufficientCreditsDetails {
requiredCredits: number;
availableCredits: number;
creditType: 'user' | 'app';
operation: string;
requiredCredits: number;
availableCredits: number;
creditType: 'user' | 'app';
operation: string;
}
export class InsufficientCreditsException extends HttpException {
constructor(details: InsufficientCreditsDetails) {
super(
{
statusCode: HttpStatus.PAYMENT_REQUIRED,
error: 'Insufficient Credits',
message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`,
details,
},
HttpStatus.PAYMENT_REQUIRED,
);
}
constructor(details: InsufficientCreditsDetails) {
super(
{
statusCode: HttpStatus.PAYMENT_REQUIRED,
error: 'Insufficient Credits',
message: `Not enough credits for ${details.operation}. Required: ${details.requiredCredits}, Available: ${details.availableCredits}`,
details,
},
HttpStatus.PAYMENT_REQUIRED
);
}
}

View file

@ -1,10 +1,10 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Inject,
Optional,
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Inject,
Optional,
} from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
@ -12,62 +12,62 @@ import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
) {}
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No authorization token provided');
}
if (!token) {
throw new UnauthorizedException('No authorization token provided');
}
try {
// Decode the token to extract user information
// The actual verification happens at the Mana Core middleware level
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
try {
// Decode the token to extract user information
// The actual verification happens at the Mana Core middleware level
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
if (!decoded || !decoded.sub) {
throw new UnauthorizedException('Invalid token structure');
}
if (!decoded || !decoded.sub) {
throw new UnauthorizedException('Invalid token structure');
}
// Attach user info to request
request.user = {
sub: decoded.sub,
email: decoded.email || '',
role: decoded.role || 'user',
app_id: decoded.app_id,
iat: decoded.iat,
exp: decoded.exp,
};
// Attach user info to request
request.user = {
sub: decoded.sub,
email: decoded.email || '',
role: decoded.role || 'user',
app_id: decoded.app_id,
iat: decoded.iat,
exp: decoded.exp,
};
// Store raw token for downstream services
request.accessToken = token;
// Store raw token for downstream services
request.accessToken = token;
if (this.options?.debug) {
console.log('[AuthGuard] User authenticated:', decoded.sub);
}
if (this.options?.debug) {
console.log('[AuthGuard] User authenticated:', decoded.sub);
}
return true;
} catch (error) {
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', error);
}
throw new UnauthorizedException('Invalid or expired token');
}
}
return true;
} catch (error) {
if (this.options?.debug) {
console.error('[AuthGuard] Token validation failed:', error);
}
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -3,9 +3,9 @@ export { ManaCoreModule, MANA_CORE_OPTIONS } from './mana-core.module';
// Interfaces
export {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
// Guards
@ -16,13 +16,13 @@ export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
// Services
export {
CreditClientService,
CreditValidationResult,
CreditBalance,
CreditClientService,
CreditValidationResult,
CreditBalance,
} from './services/credit-client.service';
// Exceptions
export {
InsufficientCreditsException,
InsufficientCreditsDetails,
InsufficientCreditsException,
InsufficientCreditsDetails,
} from './exceptions/insufficient-credits.exception';

View file

@ -1,20 +1,20 @@
import { ModuleMetadata, Type } from '@nestjs/common';
export interface ManaCoreModuleOptions {
manaServiceUrl: string;
appId: string;
serviceKey?: string;
signupRedirectUrl?: string;
debug?: boolean;
manaServiceUrl: string;
appId: string;
serviceKey?: string;
signupRedirectUrl?: string;
debug?: boolean;
}
export interface ManaCoreOptionsFactory {
createManaCoreOptions(): Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
createManaCoreOptions(): Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
}
export interface ManaCoreModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useExisting?: Type<ManaCoreOptionsFactory>;
useClass?: Type<ManaCoreOptionsFactory>;
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
inject?: any[];
useExisting?: Type<ManaCoreOptionsFactory>;
useClass?: Type<ManaCoreOptionsFactory>;
useFactory?: (...args: any[]) => Promise<ManaCoreModuleOptions> | ManaCoreModuleOptions;
inject?: any[];
}

View file

@ -1,9 +1,9 @@
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
import { HttpModule, HttpService } from '@nestjs/axios';
import {
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
ManaCoreModuleOptions,
ManaCoreModuleAsyncOptions,
ManaCoreOptionsFactory,
} from './interfaces/mana-core-options.interface';
import { AuthGuard } from './guards/auth.guard';
import { CreditClientService } from './services/credit-client.service';
@ -13,75 +13,73 @@ export const MANA_CORE_OPTIONS = 'MANA_CORE_OPTIONS';
@Global()
@Module({})
export class ManaCoreModule {
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
return {
module: ManaCoreModule,
imports: [HttpModule],
providers: [
{
provide: MANA_CORE_OPTIONS,
useValue: options,
},
AuthGuard,
CreditClientService,
],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
return {
module: ManaCoreModule,
imports: [HttpModule],
providers: [
{
provide: MANA_CORE_OPTIONS,
useValue: options,
},
AuthGuard,
CreditClientService,
],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule {
const asyncProviders = this.createAsyncProviders(options);
static forRootAsync(options: ManaCoreModuleAsyncOptions): DynamicModule {
const asyncProviders = this.createAsyncProviders(options);
return {
module: ManaCoreModule,
imports: [...(options.imports || []), HttpModule],
providers: [...asyncProviders, AuthGuard, CreditClientService],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
return {
module: ManaCoreModule,
imports: [...(options.imports || []), HttpModule],
providers: [...asyncProviders, AuthGuard, CreditClientService],
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
};
}
private static createAsyncProviders(
options: ManaCoreModuleAsyncOptions,
): Provider[] {
if (options.useFactory) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
private static createAsyncProviders(options: ManaCoreModuleAsyncOptions): Provider[] {
if (options.useFactory) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
];
}
const useClass = options.useClass;
const useExisting = options.useExisting;
const useClass = options.useClass;
const useExisting = options.useExisting;
if (useClass) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useClass],
},
{
provide: useClass,
useClass,
},
];
}
if (useClass) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useClass],
},
{
provide: useClass,
useClass,
},
];
}
if (useExisting) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useExisting],
},
];
}
if (useExisting) {
return [
{
provide: MANA_CORE_OPTIONS,
useFactory: async (optionsFactory: ManaCoreOptionsFactory) =>
await optionsFactory.createManaCoreOptions(),
inject: [useExisting],
},
];
}
return [];
}
return [];
}
}

View file

@ -5,178 +5,182 @@ import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
export interface CreditValidationResult {
hasCredits: boolean;
availableCredits: number;
requiredCredits?: number;
hasCredits: boolean;
availableCredits: number;
requiredCredits?: number;
}
export interface CreditBalance {
balance: number;
freeCreditsRemaining: number;
totalEarned: number;
totalSpent: number;
balance: number;
freeCreditsRemaining: number;
totalEarned: number;
totalSpent: number;
}
@Injectable()
export class CreditClientService {
private readonly logger = new Logger(CreditClientService.name);
private readonly logger = new Logger(CreditClientService.name);
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly httpService?: HttpService,
) {}
constructor(
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions,
@Optional()
private readonly httpService?: HttpService
) {}
async validateCredits(
userId: string,
operation: string,
requiredAmount: number,
): Promise<CreditValidationResult> {
try {
const balance = await this.getBalance(userId);
const totalAvailable = balance.balance + balance.freeCreditsRemaining;
async validateCredits(
userId: string,
operation: string,
requiredAmount: number
): Promise<CreditValidationResult> {
try {
const balance = await this.getBalance(userId);
const totalAvailable = balance.balance + balance.freeCreditsRemaining;
return {
hasCredits: totalAvailable >= requiredAmount,
availableCredits: totalAvailable,
requiredCredits: requiredAmount,
};
} catch (error) {
this.logger.error(`Failed to validate credits for user ${userId}:`, error);
// In case of error, we allow the operation to proceed
// The actual credit deduction will fail if there are no credits
return {
hasCredits: true,
availableCredits: 0,
requiredCredits: requiredAmount,
};
}
}
return {
hasCredits: totalAvailable >= requiredAmount,
availableCredits: totalAvailable,
requiredCredits: requiredAmount,
};
} catch (error) {
this.logger.error(`Failed to validate credits for user ${userId}:`, error);
// In case of error, we allow the operation to proceed
// The actual credit deduction will fail if there are no credits
return {
hasCredits: true,
availableCredits: 0,
requiredCredits: requiredAmount,
};
}
}
async getBalance(userId: string): Promise<CreditBalance> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn('HTTP service or Mana service URL not configured, returning default balance');
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};
}
async getBalance(userId: string): Promise<CreditBalance> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn(
'HTTP service or Mana service URL not configured, returning default balance'
);
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};
}
try {
const response = await firstValueFrom(
this.httpService.get<CreditBalance>(
`${this.options.manaServiceUrl}/credits/balance/${userId}`,
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
},
),
);
try {
const response = await firstValueFrom(
this.httpService.get<CreditBalance>(
`${this.options.manaServiceUrl}/credits/balance/${userId}`,
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
}
)
);
return response.data;
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}:`, error);
// Return default balance on error
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};
}
}
return response.data;
} catch (error) {
this.logger.error(`Failed to get balance for user ${userId}:`, error);
// Return default balance on error
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};
}
}
async consumeCredits(
userId: string,
operation: string,
amount: number,
description: string,
metadata?: Record<string, any>,
): Promise<boolean> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn('HTTP service or Mana service URL not configured, skipping credit consumption');
return true;
}
async consumeCredits(
userId: string,
operation: string,
amount: number,
description: string,
metadata?: Record<string, any>
): Promise<boolean> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn(
'HTTP service or Mana service URL not configured, skipping credit consumption'
);
return true;
}
try {
await firstValueFrom(
this.httpService.post(
`${this.options.manaServiceUrl}/credits/use`,
{
userId,
amount,
appId: this.options.appId,
description,
metadata: {
operation,
...metadata,
},
},
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
},
),
);
try {
await firstValueFrom(
this.httpService.post(
`${this.options.manaServiceUrl}/credits/use`,
{
userId,
amount,
appId: this.options.appId,
description,
metadata: {
operation,
...metadata,
},
},
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
}
)
);
if (this.options.debug) {
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
}
if (this.options.debug) {
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to consume credits for user ${userId}:`, error);
return false;
}
}
return true;
} catch (error) {
this.logger.error(`Failed to consume credits for user ${userId}:`, error);
return false;
}
}
async refundCredits(
userId: string,
amount: number,
description: string,
metadata?: Record<string, any>,
): Promise<boolean> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn('HTTP service or Mana service URL not configured, skipping credit refund');
return true;
}
async refundCredits(
userId: string,
amount: number,
description: string,
metadata?: Record<string, any>
): Promise<boolean> {
if (!this.httpService || !this.options?.manaServiceUrl) {
this.logger.warn('HTTP service or Mana service URL not configured, skipping credit refund');
return true;
}
try {
await firstValueFrom(
this.httpService.post(
`${this.options.manaServiceUrl}/credits/refund`,
{
userId,
amount,
appId: this.options.appId,
description,
metadata,
},
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
},
),
);
try {
await firstValueFrom(
this.httpService.post(
`${this.options.manaServiceUrl}/credits/refund`,
{
userId,
amount,
appId: this.options.appId,
description,
metadata,
},
{
headers: {
'X-Service-Key': this.options.serviceKey || '',
'X-App-Id': this.options.appId || '',
},
}
)
);
if (this.options.debug) {
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
}
if (this.options.debug) {
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
}
return true;
} catch (error) {
this.logger.error(`Failed to refund credits for user ${userId}:`, error);
return false;
}
}
return true;
} catch (error) {
this.logger.error(`Failed to refund credits for user ${userId}:`, error);
return false;
}
}
}

View file

@ -1,21 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"lib": ["ES2021"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"lib": ["ES2021"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

View file

@ -1,57 +1,57 @@
{
"name": "@manacore/manadeck-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.js",
"default": "./dist/client.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f postgres",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"db:seed": "dotenv -- tsx src/seed.ts",
"db:migrate-from-supabase": "dotenv -- tsx src/migrate-from-supabase.ts",
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
"db:test": "dotenv -- tsx src/test-connection.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"@supabase/supabase-js": "^2.81.1",
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"@types/node": "^22.10.0"
}
"name": "@manacore/manadeck-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.js",
"default": "./dist/client.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f postgres",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"db:seed": "dotenv -- tsx src/seed.ts",
"db:migrate-from-supabase": "dotenv -- tsx src/migrate-from-supabase.ts",
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
"db:test": "dotenv -- tsx src/test-connection.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"@supabase/supabase-js": "^2.81.1",
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"@types/node": "^22.10.0"
}
}

View file

@ -10,13 +10,13 @@ let pgClient: ReturnType<typeof postgres> | null = null;
* Get the database URL from environment variables
*/
function getDatabaseUrl(): string {
const url = process.env.DATABASE_URL || process.env.MANADECK_DATABASE_URL;
if (!url) {
throw new Error(
'Database URL not found. Set DATABASE_URL or MANADECK_DATABASE_URL environment variable.'
);
}
return url;
const url = process.env.DATABASE_URL || process.env.MANADECK_DATABASE_URL;
if (!url) {
throw new Error(
'Database URL not found. Set DATABASE_URL or MANADECK_DATABASE_URL environment variable.'
);
}
return url;
}
/**
@ -24,16 +24,16 @@ function getDatabaseUrl(): string {
* Uses connection pooling with sensible defaults for serverless environments
*/
export function createClient(connectionString?: string) {
const url = connectionString || getDatabaseUrl();
const url = connectionString || getDatabaseUrl();
const client = postgres(url, {
max: 10, // Maximum connections in the pool
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});
const client = postgres(url, {
max: 10, // Maximum connections in the pool
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});
return drizzle(client, { schema });
return drizzle(client, { schema });
}
/**
@ -41,17 +41,17 @@ export function createClient(connectionString?: string) {
* Creates a new instance if one doesn't exist
*/
export function getDb() {
if (!dbInstance) {
const url = getDatabaseUrl();
pgClient = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
});
dbInstance = drizzle(pgClient, { schema });
}
return dbInstance;
if (!dbInstance) {
const url = getDatabaseUrl();
pgClient = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
});
dbInstance = drizzle(pgClient, { schema });
}
return dbInstance;
}
/**
@ -59,15 +59,39 @@ export function getDb() {
* Should be called when shutting down the application
*/
export async function closeDb() {
if (pgClient) {
await pgClient.end();
pgClient = null;
dbInstance = null;
}
if (pgClient) {
await pgClient.end();
pgClient = null;
dbInstance = null;
}
}
// Export the database type for typing purposes
export type Database = ReturnType<typeof createClient>;
// Re-export commonly used Drizzle utilities
export { eq, ne, gt, gte, lt, lte, and, or, not, inArray, notInArray, isNull, isNotNull, like, ilike, sql, asc, desc, count, sum, avg, min, max } from 'drizzle-orm';
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from 'drizzle-orm';

View file

@ -5,29 +5,29 @@ export { createClient, getDb, closeDb, type Database } from './client.js';
// Export Drizzle utilities
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from './client.js';
// Export all schemas and types

View file

@ -13,14 +13,14 @@
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
import { getDb, closeDb } from './client.js';
import {
decks,
cards,
studySessions,
cardProgress,
deckTemplates,
aiGenerations,
userStats,
dailyProgress,
decks,
cards,
studySessions,
cardProgress,
deckTemplates,
aiGenerations,
userStats,
dailyProgress,
} from './schema/index.js';
// Initialize Supabase client
@ -28,210 +28,210 @@ const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables');
process.exit(1);
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables');
process.exit(1);
}
const supabase = createSupabaseClient(supabaseUrl, supabaseServiceKey);
const db = getDb();
interface MigrationStats {
table: string;
migrated: number;
errors: number;
table: string;
migrated: number;
errors: number;
}
const stats: MigrationStats[] = [];
async function migrateTable<T>(
tableName: string,
supabaseTableName: string,
drizzleTable: any,
transformer: (row: any) => T
tableName: string,
supabaseTableName: string,
drizzleTable: any,
transformer: (row: any) => T
) {
console.log(`\nMigrating ${tableName}...`);
let migrated = 0;
let errors = 0;
console.log(`\nMigrating ${tableName}...`);
let migrated = 0;
let errors = 0;
try {
// Fetch all data from Supabase
const { data, error } = await supabase.from(supabaseTableName).select('*');
try {
// Fetch all data from Supabase
const { data, error } = await supabase.from(supabaseTableName).select('*');
if (error) {
console.error(`Error fetching ${tableName}:`, error);
stats.push({ table: tableName, migrated: 0, errors: 1 });
return;
}
if (error) {
console.error(`Error fetching ${tableName}:`, error);
stats.push({ table: tableName, migrated: 0, errors: 1 });
return;
}
if (!data || data.length === 0) {
console.log(`No data found in ${tableName}`);
stats.push({ table: tableName, migrated: 0, errors: 0 });
return;
}
if (!data || data.length === 0) {
console.log(`No data found in ${tableName}`);
stats.push({ table: tableName, migrated: 0, errors: 0 });
return;
}
console.log(`Found ${data.length} rows in ${tableName}`);
console.log(`Found ${data.length} rows in ${tableName}`);
// Process in batches of 100
const batchSize = 100;
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
const transformed = batch.map(transformer);
// Process in batches of 100
const batchSize = 100;
for (let i = 0; i < data.length; i += batchSize) {
const batch = data.slice(i, i + batchSize);
const transformed = batch.map(transformer);
try {
await db.insert(drizzleTable).values(transformed).onConflictDoNothing();
migrated += batch.length;
process.stdout.write(`\r Migrated ${migrated}/${data.length} rows`);
} catch (err) {
console.error(`\n Error inserting batch:`, err);
errors += batch.length;
}
}
try {
await db.insert(drizzleTable).values(transformed).onConflictDoNothing();
migrated += batch.length;
process.stdout.write(`\r Migrated ${migrated}/${data.length} rows`);
} catch (err) {
console.error(`\n Error inserting batch:`, err);
errors += batch.length;
}
}
console.log(`\n Completed: ${migrated} migrated, ${errors} errors`);
} catch (err) {
console.error(`Error migrating ${tableName}:`, err);
errors++;
}
console.log(`\n Completed: ${migrated} migrated, ${errors} errors`);
} catch (err) {
console.error(`Error migrating ${tableName}:`, err);
errors++;
}
stats.push({ table: tableName, migrated, errors });
stats.push({ table: tableName, migrated, errors });
}
async function main() {
console.log('=== ManaDeck Data Migration ===');
console.log('From: Supabase');
console.log('To: PostgreSQL (Drizzle)');
console.log('==============================\n');
console.log('=== ManaDeck Data Migration ===');
console.log('From: Supabase');
console.log('To: PostgreSQL (Drizzle)');
console.log('==============================\n');
try {
// 1. Migrate decks
await migrateTable('decks', 'decks', decks, (row) => ({
id: row.id,
userId: row.user_id,
title: row.title,
description: row.description,
coverImageUrl: row.cover_image_url,
isPublic: row.is_public ?? false,
isFeatured: row.is_featured ?? false,
featuredAt: row.featured_at ? new Date(row.featured_at) : null,
settings: row.settings ?? {},
tags: row.tags ?? [],
metadata: row.metadata ?? {},
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
try {
// 1. Migrate decks
await migrateTable('decks', 'decks', decks, (row) => ({
id: row.id,
userId: row.user_id,
title: row.title,
description: row.description,
coverImageUrl: row.cover_image_url,
isPublic: row.is_public ?? false,
isFeatured: row.is_featured ?? false,
featuredAt: row.featured_at ? new Date(row.featured_at) : null,
settings: row.settings ?? {},
tags: row.tags ?? [],
metadata: row.metadata ?? {},
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 2. Migrate cards
await migrateTable('cards', 'cards', cards, (row) => ({
id: row.id,
deckId: row.deck_id,
position: row.position ?? 0,
title: row.title,
content: row.content,
cardType: row.card_type,
aiModel: row.ai_model,
aiPrompt: row.ai_prompt,
version: row.version ?? 1,
isFavorite: row.is_favorite ?? false,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 2. Migrate cards
await migrateTable('cards', 'cards', cards, (row) => ({
id: row.id,
deckId: row.deck_id,
position: row.position ?? 0,
title: row.title,
content: row.content,
cardType: row.card_type,
aiModel: row.ai_model,
aiPrompt: row.ai_prompt,
version: row.version ?? 1,
isFavorite: row.is_favorite ?? false,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 3. Migrate study sessions
await migrateTable('study_sessions', 'study_sessions', studySessions, (row) => ({
id: row.id,
deckId: row.deck_id,
userId: row.user_id,
mode: row.mode,
totalCards: row.total_cards ?? 0,
completedCards: row.completed_cards ?? 0,
correctCards: row.correct_cards ?? 0,
startedAt: new Date(row.started_at),
completedAt: row.completed_at ? new Date(row.completed_at) : null,
timeSpentSeconds: row.time_spent_seconds ?? 0,
}));
// 3. Migrate study sessions
await migrateTable('study_sessions', 'study_sessions', studySessions, (row) => ({
id: row.id,
deckId: row.deck_id,
userId: row.user_id,
mode: row.mode,
totalCards: row.total_cards ?? 0,
completedCards: row.completed_cards ?? 0,
correctCards: row.correct_cards ?? 0,
startedAt: new Date(row.started_at),
completedAt: row.completed_at ? new Date(row.completed_at) : null,
timeSpentSeconds: row.time_spent_seconds ?? 0,
}));
// 4. Migrate card progress
await migrateTable('card_progress', 'card_progress', cardProgress, (row) => ({
id: row.id,
userId: row.user_id,
cardId: row.card_id,
easeFactor: row.ease_factor?.toString() ?? '2.5',
interval: row.interval ?? 0,
repetitions: row.repetitions ?? 0,
lastReviewed: row.last_reviewed ? new Date(row.last_reviewed) : null,
nextReview: row.next_review ? new Date(row.next_review) : null,
status: row.status ?? 'new',
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 4. Migrate card progress
await migrateTable('card_progress', 'card_progress', cardProgress, (row) => ({
id: row.id,
userId: row.user_id,
cardId: row.card_id,
easeFactor: row.ease_factor?.toString() ?? '2.5',
interval: row.interval ?? 0,
repetitions: row.repetitions ?? 0,
lastReviewed: row.last_reviewed ? new Date(row.last_reviewed) : null,
nextReview: row.next_review ? new Date(row.next_review) : null,
status: row.status ?? 'new',
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 5. Migrate deck templates
await migrateTable('deck_templates', 'deck_templates', deckTemplates, (row) => ({
id: row.id,
title: row.title,
description: row.description,
category: row.category,
templateData: row.template_data ?? { cards: [] },
isActive: row.is_active ?? true,
isPublic: row.is_public ?? true,
popularity: row.popularity ?? 0,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 5. Migrate deck templates
await migrateTable('deck_templates', 'deck_templates', deckTemplates, (row) => ({
id: row.id,
title: row.title,
description: row.description,
category: row.category,
templateData: row.template_data ?? { cards: [] },
isActive: row.is_active ?? true,
isPublic: row.is_public ?? true,
popularity: row.popularity ?? 0,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 6. Migrate AI generations
await migrateTable('ai_generations', 'ai_generations', aiGenerations, (row) => ({
id: row.id,
userId: row.user_id,
deckId: row.deck_id,
functionName: row.function_name,
prompt: row.prompt,
model: row.model,
status: row.status ?? 'pending',
metadata: row.metadata ?? {},
completedAt: row.completed_at ? new Date(row.completed_at) : null,
createdAt: new Date(row.created_at),
}));
// 6. Migrate AI generations
await migrateTable('ai_generations', 'ai_generations', aiGenerations, (row) => ({
id: row.id,
userId: row.user_id,
deckId: row.deck_id,
functionName: row.function_name,
prompt: row.prompt,
model: row.model,
status: row.status ?? 'pending',
metadata: row.metadata ?? {},
completedAt: row.completed_at ? new Date(row.completed_at) : null,
createdAt: new Date(row.created_at),
}));
// 7. Migrate user stats
await migrateTable('user_stats', 'user_stats', userStats, (row) => ({
userId: row.user_id,
totalWins: row.total_wins ?? 0,
totalSessions: row.total_sessions ?? 0,
totalCardsStudied: row.total_cards_studied ?? 0,
totalTimeSeconds: row.total_time_seconds ?? 0,
averageAccuracy: row.average_accuracy?.toString() ?? '0',
streakDays: row.streak_days ?? 0,
longestStreak: row.longest_streak ?? 0,
lastStudyDate: row.last_study_date,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// 7. Migrate user stats
await migrateTable('user_stats', 'user_stats', userStats, (row) => ({
userId: row.user_id,
totalWins: row.total_wins ?? 0,
totalSessions: row.total_sessions ?? 0,
totalCardsStudied: row.total_cards_studied ?? 0,
totalTimeSeconds: row.total_time_seconds ?? 0,
averageAccuracy: row.average_accuracy?.toString() ?? '0',
streakDays: row.streak_days ?? 0,
longestStreak: row.longest_streak ?? 0,
lastStudyDate: row.last_study_date,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
// Print summary
console.log('\n\n=== Migration Summary ===');
console.log('-------------------------');
let totalMigrated = 0;
let totalErrors = 0;
for (const stat of stats) {
console.log(`${stat.table}: ${stat.migrated} migrated, ${stat.errors} errors`);
totalMigrated += stat.migrated;
totalErrors += stat.errors;
}
console.log('-------------------------');
console.log(`Total: ${totalMigrated} rows migrated, ${totalErrors} errors`);
// Print summary
console.log('\n\n=== Migration Summary ===');
console.log('-------------------------');
let totalMigrated = 0;
let totalErrors = 0;
for (const stat of stats) {
console.log(`${stat.table}: ${stat.migrated} migrated, ${stat.errors} errors`);
totalMigrated += stat.migrated;
totalErrors += stat.errors;
}
console.log('-------------------------');
console.log(`Total: ${totalMigrated} rows migrated, ${totalErrors} errors`);
if (totalErrors === 0) {
console.log('\n✅ Migration completed successfully!');
} else {
console.log('\n⚠ Migration completed with some errors. Please review.');
}
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeDb();
}
if (totalErrors === 0) {
console.log('\n✅ Migration completed successfully!');
} else {
console.log('\n⚠ Migration completed with some errors. Please review.');
}
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeDb();
}
}
main();

View file

@ -3,21 +3,21 @@ import { createClient } from './client.js';
import path from 'path';
async function runMigrations() {
console.log('Running migrations...');
console.log('Running migrations...');
const db = createClient();
const db = createClient();
try {
await migrate(db, {
migrationsFolder: path.join(__dirname, '../drizzle'),
});
console.log('Migrations completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
try {
await migrate(db, {
migrationsFolder: path.join(__dirname, '../drizzle'),
});
console.log('Migrations completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
process.exit(0);
process.exit(0);
}
runMigrations();

View file

@ -1,62 +1,53 @@
import {
pgTable,
uuid,
varchar,
text,
timestamp,
jsonb,
index,
pgEnum,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, text, timestamp, jsonb, index, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.js';
// AI generation status enum
export const aiGenerationStatusEnum = pgEnum('ai_generation_status', [
'pending',
'processing',
'completed',
'failed',
'pending',
'processing',
'completed',
'failed',
]);
// AI generation metadata structure
export interface AIGenerationMetadata {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
duration?: number;
error?: string;
cardCount?: number;
[key: string]: unknown;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
duration?: number;
error?: string;
cardCount?: number;
[key: string]: unknown;
}
export const aiGenerations = pgTable(
'ai_generations',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
deckId: uuid('deck_id').references(() => decks.id, { onDelete: 'set null' }),
functionName: varchar('function_name', { length: 100 }).notNull(),
prompt: text('prompt').notNull(),
model: varchar('model', { length: 100 }),
status: aiGenerationStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata').default({}).$type<AIGenerationMetadata>(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_ai_generations_user_id').on(table.userId),
index('idx_ai_generations_deck_id').on(table.deckId),
index('idx_ai_generations_status').on(table.status),
index('idx_ai_generations_created_at').on(table.createdAt),
]
'ai_generations',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
deckId: uuid('deck_id').references(() => decks.id, { onDelete: 'set null' }),
functionName: varchar('function_name', { length: 100 }).notNull(),
prompt: text('prompt').notNull(),
model: varchar('model', { length: 100 }),
status: aiGenerationStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata').default({}).$type<AIGenerationMetadata>(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_ai_generations_user_id').on(table.userId),
index('idx_ai_generations_deck_id').on(table.deckId),
index('idx_ai_generations_status').on(table.status),
index('idx_ai_generations_created_at').on(table.createdAt),
]
);
export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({
deck: one(decks, {
fields: [aiGenerations.deckId],
references: [decks.id],
}),
deck: one(decks, {
fields: [aiGenerations.deckId],
references: [decks.id],
}),
}));
export type AIGeneration = typeof aiGenerations.$inferSelect;

View file

@ -1,56 +1,56 @@
import {
pgTable,
uuid,
integer,
timestamp,
index,
pgEnum,
decimal,
unique,
pgTable,
uuid,
integer,
timestamp,
index,
pgEnum,
decimal,
unique,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { cards } from './cards.js';
// Progress status enum (SM-2 algorithm states)
export const progressStatusEnum = pgEnum('progress_status', [
'new',
'learning',
'review',
'relearning',
'new',
'learning',
'review',
'relearning',
]);
export const cardProgress = pgTable(
'card_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
cardId: uuid('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
// SM-2 algorithm fields
easeFactor: decimal('ease_factor', { precision: 4, scale: 2 }).default('2.5').notNull(),
interval: integer('interval').default(0).notNull(), // Days until next review
repetitions: integer('repetitions').default(0).notNull(),
lastReviewed: timestamp('last_reviewed', { withTimezone: true }),
nextReview: timestamp('next_review', { withTimezone: true }),
status: progressStatusEnum('status').default('new').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_card_progress_user_id').on(table.userId),
index('idx_card_progress_card_id').on(table.cardId),
index('idx_card_progress_next_review').on(table.nextReview),
index('idx_card_progress_status').on(table.status),
unique('unique_user_card').on(table.userId, table.cardId),
]
'card_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
cardId: uuid('card_id')
.notNull()
.references(() => cards.id, { onDelete: 'cascade' }),
// SM-2 algorithm fields
easeFactor: decimal('ease_factor', { precision: 4, scale: 2 }).default('2.5').notNull(),
interval: integer('interval').default(0).notNull(), // Days until next review
repetitions: integer('repetitions').default(0).notNull(),
lastReviewed: timestamp('last_reviewed', { withTimezone: true }),
nextReview: timestamp('next_review', { withTimezone: true }),
status: progressStatusEnum('status').default('new').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_card_progress_user_id').on(table.userId),
index('idx_card_progress_card_id').on(table.cardId),
index('idx_card_progress_next_review').on(table.nextReview),
index('idx_card_progress_status').on(table.status),
unique('unique_user_card').on(table.userId, table.cardId),
]
);
export const cardProgressRelations = relations(cardProgress, ({ one }) => ({
card: one(cards, {
fields: [cardProgress.cardId],
references: [cards.id],
}),
card: one(cards, {
fields: [cardProgress.cardId],
references: [cards.id],
}),
}));
export type CardProgress = typeof cardProgress.$inferSelect;

View file

@ -1,14 +1,14 @@
import {
pgTable,
uuid,
varchar,
text,
integer,
boolean,
timestamp,
jsonb,
index,
pgEnum,
pgTable,
uuid,
varchar,
text,
integer,
boolean,
timestamp,
jsonb,
index,
pgEnum,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.js';
@ -19,63 +19,63 @@ export const cardTypeEnum = pgEnum('card_type', ['text', 'flashcard', 'quiz', 'm
// Card content types
export interface TextContent {
text: string;
formatting?: {
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
text: string;
formatting?: {
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
}
export interface FlashcardContent {
front: string;
back: string;
hint?: string;
front: string;
back: string;
hint?: string;
}
export interface QuizContent {
question: string;
options: string[];
correctAnswer: number;
explanation?: string;
question: string;
options: string[];
correctAnswer: number;
explanation?: string;
}
export interface MixedContent {
sections: Array<TextContent | FlashcardContent | QuizContent>;
sections: Array<TextContent | FlashcardContent | QuizContent>;
}
export type CardContent = TextContent | FlashcardContent | QuizContent | MixedContent;
export const cards = pgTable(
'cards',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
title: varchar('title', { length: 255 }),
content: jsonb('content').notNull().$type<CardContent>(),
cardType: cardTypeEnum('card_type').notNull(),
aiModel: varchar('ai_model', { length: 100 }),
aiPrompt: text('ai_prompt'),
version: integer('version').default(1).notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_cards_deck_id').on(table.deckId),
index('idx_cards_position').on(table.deckId, table.position),
]
'cards',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
title: varchar('title', { length: 255 }),
content: jsonb('content').notNull().$type<CardContent>(),
cardType: cardTypeEnum('card_type').notNull(),
aiModel: varchar('ai_model', { length: 100 }),
aiPrompt: text('ai_prompt'),
version: integer('version').default(1).notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_cards_deck_id').on(table.deckId),
index('idx_cards_position').on(table.deckId, table.position),
]
);
export const cardsRelations = relations(cards, ({ one, many }) => ({
deck: one(decks, {
fields: [cards.deckId],
references: [decks.id],
}),
progress: many(cardProgress),
deck: one(decks, {
fields: [cards.deckId],
references: [decks.id],
}),
progress: many(cardProgress),
}));
export type Card = typeof cards.$inferSelect;

View file

@ -1,34 +1,36 @@
import {
pgTable,
uuid,
date,
integer,
decimal,
text,
timestamp,
index,
unique,
pgTable,
uuid,
date,
integer,
decimal,
text,
timestamp,
index,
unique,
} from 'drizzle-orm/pg-core';
export const dailyProgress = pgTable(
'daily_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
date: date('date').notNull(),
cardsStudied: integer('cards_studied').default(0).notNull(),
timeSpentMinutes: integer('time_spent_minutes').default(0).notNull(),
accuracyPercentage: decimal('accuracy_percentage', { precision: 5, scale: 2 }).default('0').notNull(),
decksStudied: text('decks_studied').array().default([]),
sessionsCompleted: integer('sessions_completed').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_daily_progress_user_id').on(table.userId),
index('idx_daily_progress_date').on(table.date),
unique('unique_user_date').on(table.userId, table.date),
]
'daily_progress',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
date: date('date').notNull(),
cardsStudied: integer('cards_studied').default(0).notNull(),
timeSpentMinutes: integer('time_spent_minutes').default(0).notNull(),
accuracyPercentage: decimal('accuracy_percentage', { precision: 5, scale: 2 })
.default('0')
.notNull(),
decksStudied: text('decks_studied').array().default([]),
sessionsCompleted: integer('sessions_completed').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_daily_progress_user_id').on(table.userId),
index('idx_daily_progress_date').on(table.date),
unique('unique_user_date').on(table.userId, table.date),
]
);
export type DailyProgress = typeof dailyProgress.$inferSelect;

View file

@ -1,45 +1,45 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
pgTable,
uuid,
varchar,
text,
boolean,
integer,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
// Template data structure
export interface DeckTemplateData {
cards: Array<{
title?: string;
content: Record<string, unknown>;
cardType: 'text' | 'flashcard' | 'quiz' | 'mixed';
}>;
settings?: Record<string, unknown>;
tags?: string[];
cards: Array<{
title?: string;
content: Record<string, unknown>;
cardType: 'text' | 'flashcard' | 'quiz' | 'mixed';
}>;
settings?: Record<string, unknown>;
tags?: string[];
}
export const deckTemplates = pgTable(
'deck_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
category: varchar('category', { length: 100 }),
templateData: jsonb('template_data').notNull().$type<DeckTemplateData>(),
isActive: boolean('is_active').default(true).notNull(),
isPublic: boolean('is_public').default(true).notNull(),
popularity: integer('popularity').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_deck_templates_category').on(table.category),
index('idx_deck_templates_is_active').on(table.isActive),
index('idx_deck_templates_popularity').on(table.popularity),
]
'deck_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
category: varchar('category', { length: 100 }),
templateData: jsonb('template_data').notNull().$type<DeckTemplateData>(),
isActive: boolean('is_active').default(true).notNull(),
isPublic: boolean('is_public').default(true).notNull(),
popularity: integer('popularity').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_deck_templates_category').on(table.category),
index('idx_deck_templates_is_active').on(table.isActive),
index('idx_deck_templates_popularity').on(table.popularity),
]
);
export type DeckTemplate = typeof deckTemplates.$inferSelect;

View file

@ -1,12 +1,12 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
timestamp,
jsonb,
index,
pgTable,
uuid,
varchar,
text,
boolean,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { cards } from './cards.js';
@ -14,34 +14,34 @@ import { studySessions } from './studySessions.js';
import { aiGenerations } from './aiGenerations.js';
export const decks = pgTable(
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
coverImageUrl: text('cover_image_url'),
isPublic: boolean('is_public').default(false).notNull(),
isFeatured: boolean('is_featured').default(false).notNull(),
featuredAt: timestamp('featured_at', { withTimezone: true }),
settings: jsonb('settings').default({}).$type<Record<string, unknown>>(),
tags: text('tags').array().default([]),
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_decks_user_id').on(table.userId),
index('idx_decks_is_public').on(table.isPublic),
index('idx_decks_is_featured').on(table.isFeatured),
index('idx_decks_updated_at').on(table.updatedAt),
]
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
coverImageUrl: text('cover_image_url'),
isPublic: boolean('is_public').default(false).notNull(),
isFeatured: boolean('is_featured').default(false).notNull(),
featuredAt: timestamp('featured_at', { withTimezone: true }),
settings: jsonb('settings').default({}).$type<Record<string, unknown>>(),
tags: text('tags').array().default([]),
metadata: jsonb('metadata').default({}).$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_decks_user_id').on(table.userId),
index('idx_decks_is_public').on(table.isPublic),
index('idx_decks_is_featured').on(table.isFeatured),
index('idx_decks_updated_at').on(table.updatedAt),
]
);
export const decksRelations = relations(decks, ({ many }) => ({
cards: many(cards),
studySessions: many(studySessions),
aiGenerations: many(aiGenerations),
cards: many(cards),
studySessions: many(studySessions),
aiGenerations: many(aiGenerations),
}));
export type Deck = typeof decks.$inferSelect;

View file

@ -1,12 +1,4 @@
import {
pgTable,
uuid,
varchar,
integer,
timestamp,
index,
pgEnum,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, integer, timestamp, index, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { decks } from './decks.js';
@ -14,33 +6,33 @@ import { decks } from './decks.js';
export const studyModeEnum = pgEnum('study_mode', ['all', 'new', 'review', 'favorites', 'random']);
export const studySessions = pgTable(
'study_sessions',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
mode: studyModeEnum('mode').notNull(),
totalCards: integer('total_cards').notNull().default(0),
completedCards: integer('completed_cards').notNull().default(0),
correctCards: integer('correct_cards').notNull().default(0),
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
timeSpentSeconds: integer('time_spent_seconds').default(0).notNull(),
},
(table) => [
index('idx_study_sessions_user_id').on(table.userId),
index('idx_study_sessions_deck_id').on(table.deckId),
index('idx_study_sessions_started_at').on(table.startedAt),
]
'study_sessions',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => decks.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
mode: studyModeEnum('mode').notNull(),
totalCards: integer('total_cards').notNull().default(0),
completedCards: integer('completed_cards').notNull().default(0),
correctCards: integer('correct_cards').notNull().default(0),
startedAt: timestamp('started_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
timeSpentSeconds: integer('time_spent_seconds').default(0).notNull(),
},
(table) => [
index('idx_study_sessions_user_id').on(table.userId),
index('idx_study_sessions_deck_id').on(table.deckId),
index('idx_study_sessions_started_at').on(table.startedAt),
]
);
export const studySessionsRelations = relations(studySessions, ({ one }) => ({
deck: one(decks, {
fields: [studySessions.deckId],
references: [decks.id],
}),
deck: one(decks, {
fields: [studySessions.deckId],
references: [decks.id],
}),
}));
export type StudySession = typeof studySessions.$inferSelect;

View file

@ -1,32 +1,24 @@
import {
pgTable,
uuid,
integer,
decimal,
date,
timestamp,
index,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, integer, decimal, date, timestamp, index } from 'drizzle-orm/pg-core';
export const userStats = pgTable(
'user_stats',
{
userId: uuid('user_id').primaryKey(),
totalWins: integer('total_wins').default(0).notNull(),
totalSessions: integer('total_sessions').default(0).notNull(),
totalCardsStudied: integer('total_cards_studied').default(0).notNull(),
totalTimeSeconds: integer('total_time_seconds').default(0).notNull(),
averageAccuracy: decimal('average_accuracy', { precision: 5, scale: 2 }).default('0').notNull(),
streakDays: integer('streak_days').default(0).notNull(),
longestStreak: integer('longest_streak').default(0).notNull(),
lastStudyDate: date('last_study_date'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_user_stats_total_wins').on(table.totalWins),
index('idx_user_stats_streak_days').on(table.streakDays),
]
'user_stats',
{
userId: uuid('user_id').primaryKey(),
totalWins: integer('total_wins').default(0).notNull(),
totalSessions: integer('total_sessions').default(0).notNull(),
totalCardsStudied: integer('total_cards_studied').default(0).notNull(),
totalTimeSeconds: integer('total_time_seconds').default(0).notNull(),
averageAccuracy: decimal('average_accuracy', { precision: 5, scale: 2 }).default('0').notNull(),
streakDays: integer('streak_days').default(0).notNull(),
longestStreak: integer('longest_streak').default(0).notNull(),
lastStudyDate: date('last_study_date'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('idx_user_stats_total_wins').on(table.totalWins),
index('idx_user_stats_streak_days').on(table.streakDays),
]
);
export type UserStats = typeof userStats.$inferSelect;

View file

@ -5,101 +5,101 @@ import { deckTemplates } from './schema/index.js';
* Seed the database with initial data
*/
async function seed() {
console.log('Seeding database...');
console.log('Seeding database...');
const db = getDb();
const db = getDb();
try {
// Seed deck templates
const templates = [
{
title: 'Language Basics',
description: 'Learn basic vocabulary and phrases for a new language',
category: 'languages',
templateData: {
cards: [
{
cardType: 'flashcard' as const,
content: { front: 'Hello', back: 'Hallo', hint: 'Greeting' },
},
{
cardType: 'flashcard' as const,
content: { front: 'Goodbye', back: 'Auf Wiedersehen' },
},
],
settings: { language: 'de' },
tags: ['language', 'basics', 'german'],
},
isActive: true,
isPublic: true,
popularity: 100,
},
{
title: 'Math Fundamentals',
description: 'Essential math concepts and formulas',
category: 'education',
templateData: {
cards: [
{
cardType: 'quiz' as const,
content: {
question: 'What is 2 + 2?',
options: ['3', '4', '5', '6'],
correctAnswer: 1,
explanation: '2 + 2 equals 4',
},
},
],
settings: { difficulty: 'beginner' },
tags: ['math', 'basics'],
},
isActive: true,
isPublic: true,
popularity: 80,
},
{
title: 'Programming Concepts',
description: 'Core programming concepts and terminology',
category: 'technology',
templateData: {
cards: [
{
cardType: 'flashcard' as const,
content: {
front: 'Variable',
back: 'A named storage location in memory that holds a value',
},
},
{
cardType: 'flashcard' as const,
content: {
front: 'Function',
back: 'A reusable block of code that performs a specific task',
},
},
],
settings: {},
tags: ['programming', 'coding', 'basics'],
},
isActive: true,
isPublic: true,
popularity: 90,
},
];
try {
// Seed deck templates
const templates = [
{
title: 'Language Basics',
description: 'Learn basic vocabulary and phrases for a new language',
category: 'languages',
templateData: {
cards: [
{
cardType: 'flashcard' as const,
content: { front: 'Hello', back: 'Hallo', hint: 'Greeting' },
},
{
cardType: 'flashcard' as const,
content: { front: 'Goodbye', back: 'Auf Wiedersehen' },
},
],
settings: { language: 'de' },
tags: ['language', 'basics', 'german'],
},
isActive: true,
isPublic: true,
popularity: 100,
},
{
title: 'Math Fundamentals',
description: 'Essential math concepts and formulas',
category: 'education',
templateData: {
cards: [
{
cardType: 'quiz' as const,
content: {
question: 'What is 2 + 2?',
options: ['3', '4', '5', '6'],
correctAnswer: 1,
explanation: '2 + 2 equals 4',
},
},
],
settings: { difficulty: 'beginner' },
tags: ['math', 'basics'],
},
isActive: true,
isPublic: true,
popularity: 80,
},
{
title: 'Programming Concepts',
description: 'Core programming concepts and terminology',
category: 'technology',
templateData: {
cards: [
{
cardType: 'flashcard' as const,
content: {
front: 'Variable',
back: 'A named storage location in memory that holds a value',
},
},
{
cardType: 'flashcard' as const,
content: {
front: 'Function',
back: 'A reusable block of code that performs a specific task',
},
},
],
settings: {},
tags: ['programming', 'coding', 'basics'],
},
isActive: true,
isPublic: true,
popularity: 90,
},
];
console.log('Inserting deck templates...');
await db.insert(deckTemplates).values(templates).onConflictDoNothing();
console.log('Inserting deck templates...');
await db.insert(deckTemplates).values(templates).onConflictDoNothing();
console.log('Seeding completed successfully!');
} catch (error) {
console.error('Seeding failed:', error);
throw error;
} finally {
await closeDb();
}
console.log('Seeding completed successfully!');
} catch (error) {
console.error('Seeding failed:', error);
throw error;
} finally {
await closeDb();
}
}
seed().catch((error) => {
console.error('Seed script failed:', error);
process.exit(1);
console.error('Seed script failed:', error);
process.exit(1);
});

View file

@ -6,39 +6,39 @@
import { getDb, closeDb, sql } from './client.js';
async function testConnection() {
console.log('Testing database connection...\n');
console.log('Testing database connection...\n');
try {
const db = getDb();
try {
const db = getDb();
// Test basic connection
const result = await db.execute(sql`SELECT NOW() as current_time, version() as pg_version`);
console.log('✅ Connection successful!');
console.log(` Time: ${result[0].current_time}`);
console.log(` PostgreSQL: ${result[0].pg_version}\n`);
// Test basic connection
const result = await db.execute(sql`SELECT NOW() as current_time, version() as pg_version`);
console.log('✅ Connection successful!');
console.log(` Time: ${result[0].current_time}`);
console.log(` PostgreSQL: ${result[0].pg_version}\n`);
// List tables
const tables = await db.execute(sql`
// List tables
const tables = await db.execute(sql`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename
`);
if (tables.length > 0) {
console.log('📋 Tables in database:');
tables.forEach((t: any) => console.log(` - ${t.tablename}`));
} else {
console.log('📋 No tables found. Run "pnpm db:push" to create schema.');
}
if (tables.length > 0) {
console.log('📋 Tables in database:');
tables.forEach((t: any) => console.log(` - ${t.tablename}`));
} else {
console.log('📋 No tables found. Run "pnpm db:push" to create schema.');
}
console.log('\n✅ All tests passed!');
} catch (error) {
console.error('❌ Connection failed:', error);
process.exit(1);
} finally {
await closeDb();
}
console.log('\n✅ All tests passed!');
} catch (error) {
console.error('❌ Connection failed:', error);
process.exit(1);
} finally {
await closeDb();
}
}
testConnection();

View file

@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -6,10 +6,10 @@ import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
export default defineConfig({
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://news:news_dev_password@localhost:5434/news_hub',
},
schema: './src/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://news:news_dev_password@localhost:5434/news_hub',
},
});

View file

@ -1,43 +1,43 @@
{
"name": "@manacore/news-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0"
}
"name": "@manacore/news-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0"
}
}

View file

@ -14,6 +14,6 @@ export type Database = PostgresJsDatabase<typeof schema>;
// Helper to create a new database connection
export function createDb(url: string): Database {
const client = postgres(url);
return drizzle(client, { schema });
const client = postgres(url);
return drizzle(client, { schema });
}

View file

@ -1,4 +1,14 @@
import { pgTable, uuid, text, timestamp, boolean, integer, real, pgEnum, index } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
real,
pgEnum,
index,
} from 'drizzle-orm/pg-core';
import { users } from './users';
import { categories } from './categories';
@ -6,59 +16,63 @@ export const articleTypeEnum = pgEnum('article_type', ['feed', 'summary', 'in_de
export const articleSourceEnum = pgEnum('article_source', ['ai', 'user_saved']);
export const summaryPeriodEnum = pgEnum('summary_period', ['morning', 'noon', 'evening', 'night']);
export const articles = pgTable('articles', {
id: uuid('id').primaryKey().defaultRandom(),
export const articles = pgTable(
'articles',
{
id: uuid('id').primaryKey().defaultRandom(),
// Core fields
type: articleTypeEnum('type').notNull(),
sourceOrigin: articleSourceEnum('source_origin').default('ai').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
summary: text('summary'),
// Core fields
type: articleTypeEnum('type').notNull(),
sourceOrigin: articleSourceEnum('source_origin').default('ai').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
summary: text('summary'),
// For user-saved articles
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
originalUrl: text('original_url'),
parsedContent: text('parsed_content'),
isArchived: boolean('is_archived').default(false),
// For user-saved articles
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
originalUrl: text('original_url'),
parsedContent: text('parsed_content'),
isArchived: boolean('is_archived').default(false),
// Metadata
categoryId: uuid('category_id').references(() => categories.id),
sourceUrl: text('source_url'),
sourceName: text('source_name'),
sourceDomain: text('source_domain'),
author: text('author'),
imageUrl: text('image_url'),
// Metadata
categoryId: uuid('category_id').references(() => categories.id),
sourceUrl: text('source_url'),
sourceName: text('source_name'),
sourceDomain: text('source_domain'),
author: text('author'),
imageUrl: text('image_url'),
// AI-generated metadata
aiTags: text('ai_tags').array(),
sentimentScore: real('sentiment_score'),
// AI-generated metadata
aiTags: text('ai_tags').array(),
sentimentScore: real('sentiment_score'),
// Reading metrics
readingTimeMinutes: integer('reading_time_minutes'),
wordCount: integer('word_count'),
// Reading metrics
readingTimeMinutes: integer('reading_time_minutes'),
wordCount: integer('word_count'),
// Summary-specific fields
summaryDate: timestamp('summary_date'),
summaryPeriod: summaryPeriodEnum('summary_period'),
includedArticleIds: uuid('included_article_ids').array(),
// Summary-specific fields
summaryDate: timestamp('summary_date'),
summaryPeriod: summaryPeriodEnum('summary_period'),
includedArticleIds: uuid('included_article_ids').array(),
// In-depth specific fields
keyInsights: text('key_insights'), // JSON string
dataVisualizations: text('data_visualizations'), // JSON string
relatedArticleIds: uuid('related_article_ids').array(),
// In-depth specific fields
keyInsights: text('key_insights'), // JSON string
dataVisualizations: text('data_visualizations'), // JSON string
relatedArticleIds: uuid('related_article_ids').array(),
// Timestamps
publishedAt: timestamp('published_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('articles_type_idx').on(table.type),
index('articles_user_idx').on(table.userId),
index('articles_source_origin_idx').on(table.sourceOrigin),
index('articles_published_at_idx').on(table.publishedAt),
index('articles_category_idx').on(table.categoryId),
]);
// Timestamps
publishedAt: timestamp('published_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [
index('articles_type_idx').on(table.type),
index('articles_user_idx').on(table.userId),
index('articles_source_origin_idx').on(table.sourceOrigin),
index('articles_published_at_idx').on(table.publishedAt),
index('articles_category_idx').on(table.categoryId),
]
);
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;

View file

@ -3,40 +3,44 @@ import { users } from './users';
// Better Auth Sessions
export const sessions = pgTable('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Better Auth Accounts (for OAuth providers)
export const accounts = pgTable('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
providerId: text('provider_id').notNull(), // 'credential', 'google', 'apple', etc.
accountId: text('account_id').notNull(), // Provider's user ID or email for credential
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'), // Hashed, only for credential provider
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
providerId: text('provider_id').notNull(), // 'credential', 'google', 'apple', etc.
accountId: text('account_id').notNull(), // Provider's user ID or email for credential
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'), // Hashed, only for credential provider
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// Better Auth Verification Tokens
export const verifications = pgTable('verifications', {
id: uuid('id').primaryKey().defaultRandom(),
identifier: text('identifier').notNull(), // email or other identifier
value: text('value').notNull(), // the token
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
identifier: text('identifier').notNull(), // email or other identifier
value: text('value').notNull(), // the token
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type Session = typeof sessions.$inferSelect;

View file

@ -1,15 +1,15 @@
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const categories = pgTable('categories', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
displayName: text('display_name').notNull(),
description: text('description'),
icon: text('icon'),
color: text('color'),
priority: integer('priority').default(0).notNull(),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
displayName: text('display_name').notNull(),
description: text('description'),
icon: text('icon'),
color: text('color'),
priority: integer('priority').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type Category = typeof categories.$inferSelect;

View file

@ -1,31 +1,48 @@
import { pgTable, uuid, timestamp, boolean, real, integer, index, unique } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
timestamp,
boolean,
real,
integer,
index,
unique,
} from 'drizzle-orm/pg-core';
import { users } from './users';
import { articles } from './articles';
export const userArticleInteractions = pgTable('user_article_interactions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
articleId: uuid('article_id').references(() => articles.id, { onDelete: 'cascade' }).notNull(),
export const userArticleInteractions = pgTable(
'user_article_interactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
articleId: uuid('article_id')
.references(() => articles.id, { onDelete: 'cascade' })
.notNull(),
// Interaction states
isRead: boolean('is_read').default(false).notNull(),
isSaved: boolean('is_saved').default(false).notNull(),
readProgress: real('read_progress').default(0), // 0.0 to 1.0
rating: integer('rating'), // 1-5
shareCount: integer('share_count').default(0).notNull(),
// Interaction states
isRead: boolean('is_read').default(false).notNull(),
isSaved: boolean('is_saved').default(false).notNull(),
readProgress: real('read_progress').default(0), // 0.0 to 1.0
rating: integer('rating'), // 1-5
shareCount: integer('share_count').default(0).notNull(),
// Timestamps
openedAt: timestamp('opened_at'),
readAt: timestamp('read_at'),
savedAt: timestamp('saved_at'),
ratedAt: timestamp('rated_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
unique('user_article_unique').on(table.userId, table.articleId),
index('interactions_user_idx').on(table.userId),
index('interactions_article_idx').on(table.articleId),
]);
// Timestamps
openedAt: timestamp('opened_at'),
readAt: timestamp('read_at'),
savedAt: timestamp('saved_at'),
ratedAt: timestamp('rated_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [
unique('user_article_unique').on(table.userId, table.articleId),
index('interactions_user_idx').on(table.userId),
index('interactions_article_idx').on(table.articleId),
]
);
export type UserArticleInteraction = typeof userArticleInteractions.$inferSelect;
export type NewUserArticleInteraction = typeof userArticleInteractions.$inferInsert;

View file

@ -4,25 +4,25 @@ export const userTierEnum = pgEnum('user_tier', ['free', 'premium', 'enterprise'
export const readingSpeedEnum = pgEnum('reading_speed', ['slow', 'normal', 'fast']);
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name'),
avatarUrl: text('avatar_url'),
emailVerified: boolean('email_verified').default(false).notNull(),
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name'),
avatarUrl: text('avatar_url'),
emailVerified: boolean('email_verified').default(false).notNull(),
// Preferences
tier: userTierEnum('tier').default('free').notNull(),
readingSpeed: readingSpeedEnum('reading_speed').default('normal').notNull(),
preferredCategories: text('preferred_categories').array(),
blockedSources: text('blocked_sources').array(),
// Preferences
tier: userTierEnum('tier').default('free').notNull(),
readingSpeed: readingSpeedEnum('reading_speed').default('normal').notNull(),
preferredCategories: text('preferred_categories').array(),
blockedSources: text('blocked_sources').array(),
// Settings
onboardingCompleted: boolean('onboarding_completed').default(false).notNull(),
notificationSettings: text('notification_settings'), // JSON string
// Settings
onboardingCompleted: boolean('onboarding_completed').default(false).notNull(),
notificationSettings: text('notification_settings'), // JSON string
// Timestamps
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
// Timestamps
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;

View file

@ -1,21 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './dist/schema/index.js',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL || '',
},
verbose: true,
strict: true,
schema: './dist/schema/index.js',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL || '',
},
verbose: true,
strict: true,
});

View file

@ -1,54 +1,54 @@
{
"name": "@manacore/nutriphi-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.js",
"default": "./dist/client.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f postgres",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
"db:test": "dotenv -- tsx src/test-connection.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"@types/node": "^22.10.0"
}
"name": "@manacore/nutriphi-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.js",
"default": "./dist/client.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f postgres",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
"db:test": "dotenv -- tsx src/test-connection.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"@types/node": "^22.10.0"
}
}

View file

@ -10,13 +10,13 @@ let pgClient: ReturnType<typeof postgres> | null = null;
* Get the database URL from environment variables
*/
function getDatabaseUrl(): string {
const url = process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL;
if (!url) {
throw new Error(
'Database URL not found. Set DATABASE_URL or NUTRIPHI_DATABASE_URL environment variable.'
);
}
return url;
const url = process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL;
if (!url) {
throw new Error(
'Database URL not found. Set DATABASE_URL or NUTRIPHI_DATABASE_URL environment variable.'
);
}
return url;
}
/**
@ -24,16 +24,16 @@ function getDatabaseUrl(): string {
* Uses connection pooling with sensible defaults for serverless environments
*/
export function createClient(connectionString?: string) {
const url = connectionString || getDatabaseUrl();
const url = connectionString || getDatabaseUrl();
const client = postgres(url, {
max: 10, // Maximum connections in the pool
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});
const client = postgres(url, {
max: 10, // Maximum connections in the pool
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});
return drizzle(client, { schema });
return drizzle(client, { schema });
}
/**
@ -41,17 +41,17 @@ export function createClient(connectionString?: string) {
* Creates a new instance if one doesn't exist
*/
export function getDb() {
if (!dbInstance) {
const url = getDatabaseUrl();
pgClient = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
});
dbInstance = drizzle(pgClient, { schema });
}
return dbInstance;
if (!dbInstance) {
const url = getDatabaseUrl();
pgClient = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
});
dbInstance = drizzle(pgClient, { schema });
}
return dbInstance;
}
/**
@ -59,11 +59,11 @@ export function getDb() {
* Should be called when shutting down the application
*/
export async function closeDb() {
if (pgClient) {
await pgClient.end();
pgClient = null;
dbInstance = null;
}
if (pgClient) {
await pgClient.end();
pgClient = null;
dbInstance = null;
}
}
// Export the database type for typing purposes
@ -71,27 +71,27 @@ export type Database = ReturnType<typeof createClient>;
// Re-export commonly used Drizzle utilities
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from 'drizzle-orm';

View file

@ -3,29 +3,29 @@ export { createClient, getDb, closeDb, type Database } from './client.js';
// Re-export Drizzle utilities
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from './client.js';
// Schema exports

View file

@ -4,16 +4,16 @@ import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
* Nutrition goals table - stores user's daily nutrition targets
*/
export const nutritionGoals = pgTable('nutrition_goals', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
caloriesTarget: integer('calories_target').notNull(),
proteinTarget: integer('protein_target').notNull(),
carbsTarget: integer('carbs_target').notNull(),
fatTarget: integer('fat_target').notNull(),
fiberTarget: integer('fiber_target'),
sugarLimit: integer('sugar_limit'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
caloriesTarget: integer('calories_target').notNull(),
proteinTarget: integer('protein_target').notNull(),
carbsTarget: integer('carbs_target').notNull(),
fatTarget: integer('fat_target').notNull(),
fiberTarget: integer('fiber_target'),
sugarLimit: integer('sugar_limit'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Type exports

View file

@ -1,65 +1,64 @@
import {
pgTable,
uuid,
text,
integer,
real,
timestamp,
index,
jsonb,
} from 'drizzle-orm/pg-core';
import { pgTable, uuid, text, integer, real, timestamp, index, jsonb } from 'drizzle-orm/pg-core';
/**
* Meals table - stores all meal entries with nutrition data
*/
export const meals = pgTable(
'meals',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
foodName: text('food_name').notNull(),
imageUrl: text('image_url'),
storagePath: text('storage_path'), // R2 path for deletion
calories: real('calories').default(0),
protein: real('protein').default(0),
carbohydrates: real('carbohydrates').default(0),
fat: real('fat').default(0),
fiber: real('fiber').default(0),
sugar: real('sugar').default(0),
sodium: real('sodium').default(0),
servingSize: text('serving_size'),
mealType: text('meal_type'), // breakfast | lunch | dinner | snack
analysisStatus: text('analysis_status').default('pending'), // pending | completed | failed | manual
healthScore: integer('health_score'), // 1-10
healthCategory: text('health_category'), // very_healthy | healthy | moderate | unhealthy
notes: text('notes'),
userRating: integer('user_rating'), // 1-5
foodItems: jsonb('food_items').$type<FoodItem[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('meals_user_id_idx').on(table.userId),
index('meals_created_at_idx').on(table.createdAt),
index('meals_user_created_idx').on(table.userId, table.createdAt),
]
'meals',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
foodName: text('food_name').notNull(),
imageUrl: text('image_url'),
storagePath: text('storage_path'), // R2 path for deletion
calories: real('calories').default(0),
protein: real('protein').default(0),
carbohydrates: real('carbohydrates').default(0),
fat: real('fat').default(0),
fiber: real('fiber').default(0),
sugar: real('sugar').default(0),
sodium: real('sodium').default(0),
servingSize: text('serving_size'),
mealType: text('meal_type'), // breakfast | lunch | dinner | snack
analysisStatus: text('analysis_status').default('pending'), // pending | completed | failed | manual
healthScore: integer('health_score'), // 1-10
healthCategory: text('health_category'), // very_healthy | healthy | moderate | unhealthy
notes: text('notes'),
userRating: integer('user_rating'), // 1-5
foodItems: jsonb('food_items').$type<FoodItem[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('meals_user_id_idx').on(table.userId),
index('meals_created_at_idx').on(table.createdAt),
index('meals_user_created_idx').on(table.userId, table.createdAt),
]
);
/**
* Food item type for meal ingredients
*/
export interface FoodItem {
id: string;
name: string;
category: 'protein' | 'vegetable' | 'grain' | 'fruit' | 'dairy' | 'fat' | 'processed' | 'beverage';
portionSize: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
id: string;
name: string;
category:
| 'protein'
| 'vegetable'
| 'grain'
| 'fruit'
| 'dairy'
| 'fat'
| 'processed'
| 'beverage';
portionSize: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
}
// Type exports

View file

@ -1,21 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,32 +1,32 @@
{
"name": "@manacore/shared-auth-stores",
"version": "0.0.1",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
}
"name": "@manacore/shared-auth-stores",
"version": "0.0.1",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@manacore/shared-types": "workspace:*"
}
}

View file

@ -181,6 +181,6 @@ export function createAuthStore<TUser extends BaseUser>(authService: AuthService
*/
clearError() {
error = null;
}
},
};
}

View file

@ -77,12 +77,12 @@ export function createSupabaseAuthStore(
async function getUserFromSession(): Promise<SupabaseUser | null> {
const supabase = getSupabaseClient();
const {
data: { session }
data: { session },
} = await supabase.auth.getSession();
if (!session?.user) return null;
return {
id: session.user.id,
email: session.user.email || ''
email: session.user.email || '',
};
}
@ -136,7 +136,7 @@ export function createSupabaseAuthStore(
const supabase = getSupabaseClient();
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password
password,
});
if (authError) {
@ -165,7 +165,7 @@ export function createSupabaseAuthStore(
const supabase = getSupabaseClient();
const { data, error: authError } = await supabase.auth.signUp({
email,
password
password,
});
if (authError) {
@ -183,7 +183,7 @@ export function createSupabaseAuthStore(
return {
success: true,
needsVerification,
user: user || undefined
user: user || undefined,
};
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Sign up failed';
@ -206,7 +206,7 @@ export function createSupabaseAuthStore(
options.passwordResetRedirectUrl || `${window.location.origin}/reset-password`;
const { error: authError } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo
redirectTo,
});
if (authError) {
@ -266,6 +266,6 @@ export function createSupabaseAuthStore(
*/
clearError() {
error = null;
}
},
};
}

View file

@ -29,7 +29,10 @@
// Factory functions
export { createAuthStore } from './createAuthStore.svelte';
export { createSupabaseAuthStore } from './createSupabaseAuthStore.svelte';
export type { CreateSupabaseAuthStoreOptions, SupabaseUser } from './createSupabaseAuthStore.svelte';
export type {
CreateSupabaseAuthStoreOptions,
SupabaseUser,
} from './createSupabaseAuthStore.svelte';
// Types
export type {
@ -38,5 +41,5 @@ export type {
AuthServiceAdapter,
AuthStoreState,
AuthStoreActions,
AuthStore
AuthStore,
} from './types';

View file

@ -1,18 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,27 +1,27 @@
{
"name": "@manacore/shared-auth-ui",
"version": "1.0.0",
"description": "Shared authentication UI components for Mana apps",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"src"
],
"peerDependencies": {
"svelte": "^5.0.0",
"@manacore/shared-auth": "workspace:*"
},
"devDependencies": {
"svelte": "^5.16.0",
"typescript": "^5.7.3"
}
"name": "@manacore/shared-auth-ui",
"version": "1.0.0",
"description": "Shared authentication UI components for Mana apps",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"src"
],
"peerDependencies": {
"svelte": "^5.0.0",
"@manacore/shared-auth": "workspace:*"
},
"devDependencies": {
"svelte": "^5.16.0",
"typescript": "^5.7.3"
}
}

View file

@ -55,7 +55,9 @@
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl bg-black border border-gray-800 px-4 font-medium text-white transition-all hover:bg-gray-900 disabled:opacity-50"
>
{#if isLoading}
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
></div>
{:else}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path

View file

@ -39,7 +39,7 @@
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'pill'
shape: 'pill',
});
}
} catch (err) {
@ -55,10 +55,18 @@
</div>
{/if}
<div bind:this={buttonContainer} class="relative w-full google-btn-wrapper" style="min-height: 56px;">
<div
bind:this={buttonContainer}
class="relative w-full google-btn-wrapper"
style="min-height: 56px;"
>
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center rounded-xl bg-white/80 dark:bg-black/80 backdrop-blur-sm z-10">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
<div
class="absolute inset-0 flex items-center justify-center rounded-xl bg-white/80 dark:bg-black/80 backdrop-blur-sm z-10"
>
<div
class="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"
></div>
</div>
{/if}
</div>

View file

@ -3,39 +3,52 @@
* Only includes icons used in auth UI
*/
export const iconPaths = {
'user-plus': '<path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.26-10.3C32.09,177.09,52.67,163.91,76,157.53a68,68,0,1,1,64,0C163.33,163.91,183.91,177.09,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z"/>',
'user-plus':
'<path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.26-10.3C32.09,177.09,52.67,163.91,76,157.53a68,68,0,1,1,64,0C163.33,163.91,183.91,177.09,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z"/>',
'sign-in': '<path d="M144,136v-16a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v16a8,8,0,0,0,8,8h88A8,8,0,0,0,144,136Zm-27.31,52.69a8,8,0,0,0-11.32,11.31L136,230.63V224a8,8,0,0,0-16,0v24a8,8,0,0,0,8,8h24a8,8,0,0,0,0-16h-6.63l30.32-30.31a8,8,0,0,0-11.31-11.32l-24,24ZM200,32H136a8,8,0,0,0,0,16h64V208H136a8,8,0,0,0,0,16h64a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Z"/>',
'sign-in':
'<path d="M144,136v-16a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v16a8,8,0,0,0,8,8h88A8,8,0,0,0,144,136Zm-27.31,52.69a8,8,0,0,0-11.32,11.31L136,230.63V224a8,8,0,0,0-16,0v24a8,8,0,0,0,8,8h24a8,8,0,0,0,0-16h-6.63l30.32-30.31a8,8,0,0,0-11.31-11.32l-24,24ZM200,32H136a8,8,0,0,0,0,16h64V208H136a8,8,0,0,0,0,16h64a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Z"/>',
'eye': '<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/>',
eye: '<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/>',
'eye-off': '<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.58A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/>',
'eye-off':
'<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.58A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/>',
'key': '<path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/>',
key: '<path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/>',
'arrow-left': '<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/>',
'arrow-left':
'<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/>',
'info': '<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/>',
info: '<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/>',
'mail-open': '<path d="M228.44,89.34l-96-64a8,8,0,0,0-8.88,0l-96,64A8,8,0,0,0,24,96V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V96A8,8,0,0,0,228.44,89.34ZM128,41.61l81.91,54.61-67,47.78a8,8,0,0,1-9.79,0l-67-47.78ZM40,200V111.53l65.9,47a24,24,0,0,0,29.44,0l65.9-47L216,200Z"/>',
'mail-open':
'<path d="M228.44,89.34l-96-64a8,8,0,0,0-8.88,0l-96,64A8,8,0,0,0,24,96V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V96A8,8,0,0,0,228.44,89.34ZM128,41.61l81.91,54.61-67,47.78a8,8,0,0,1-9.79,0l-67-47.78ZM40,200V111.53l65.9,47a24,24,0,0,0,29.44,0l65.9-47L216,200Z"/>',
'lock': '<path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/>',
lock: '<path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/>',
'shield-check': '<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,26.07,47.48,26.53a8,8,0,0,0,5.18,0c1.52-.46,24.42-7.67,47.48-26.53C200.48,196.67,226,164.72,226,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.58,89.42A132.87,132.87,0,0,1,128,224.54a132.77,132.77,0,0,1-39.42-23.12C61.66,179.16,48,149.07,48,112V56H208ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z"/>',
'shield-check':
'<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,26.07,47.48,26.53a8,8,0,0,0,5.18,0c1.52-.46,24.42-7.67,47.48-26.53C200.48,196.67,226,164.72,226,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.58,89.42A132.87,132.87,0,0,1,128,224.54a132.77,132.77,0,0,1-39.42-23.12C61.66,179.16,48,149.07,48,112V56H208ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z"/>',
'arrows-left-right': '<path d="M45.66,77.66A8,8,0,0,1,40,64h80a8,8,0,0,1,0,16H59.31l18.35,18.34a8,8,0,0,1-11.32,11.32ZM216,176H136a8,8,0,0,0,0,16h60.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32a8,8,0,0,0,0-11.32l-32-32a8,8,0,0,0-11.32,11.32L196.69,176Z"/>',
'arrows-left-right':
'<path d="M45.66,77.66A8,8,0,0,1,40,64h80a8,8,0,0,1,0,16H59.31l18.35,18.34a8,8,0,0,1-11.32,11.32ZM216,176H136a8,8,0,0,0,0,16h60.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32a8,8,0,0,0,0-11.32l-32-32a8,8,0,0,0-11.32,11.32L196.69,176Z"/>',
'envelope': '<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/>',
envelope:
'<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/>',
'folder': '<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"/>',
folder:
'<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"/>',
'music': '<path d="M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.09A36,36,0,1,0,88,196V62.25l112-28v99.84A36,36,0,1,0,216,168V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216Zm128-32a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z"/>',
music:
'<path d="M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.09A36,36,0,1,0,88,196V62.25l112-28v99.84A36,36,0,1,0,216,168V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216Zm128-32a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z"/>',
'refresh': '<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>',
refresh:
'<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>',
'check': '<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/>',
check:
'<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/>',
'warning': '<path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/>'
warning:
'<path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/>',
} as const;
export type IconName = keyof typeof iconPaths;

View file

@ -10,32 +10,27 @@ export { default as AppleSignInButton } from './components/AppleSignInButton.sve
// Utilities
export {
setGoogleClientId,
initializeGoogleAuth,
renderGoogleButton,
isGoogleAuthLoaded,
waitForGoogleAuth
setGoogleClientId,
initializeGoogleAuth,
renderGoogleButton,
isGoogleAuthLoaded,
waitForGoogleAuth,
} from './utils/googleAuth';
export {
setAppleConfig,
initializeAppleAuth,
signInWithApple,
parseAppleAuthorizationResponse,
getStoredReturnUrl,
clearAppleSignInSession,
isAppleAuthLoaded,
waitForAppleAuth,
type AppleAuthorizationResponse
setAppleConfig,
initializeAppleAuth,
signInWithApple,
parseAppleAuthorizationResponse,
getStoredReturnUrl,
clearAppleSignInSession,
isAppleAuthLoaded,
waitForAppleAuth,
type AppleAuthorizationResponse,
} from './utils/appleAuth';
// Types
export type {
AuthUIConfig,
AuthServiceInterface,
AuthResult,
IconName
} from './types';
export type { AuthUIConfig, AuthServiceInterface, AuthResult, IconName } from './types';
// Page Translation Types
export type { LoginTranslations } from './pages/LoginPage.svelte';

View file

@ -34,7 +34,7 @@
resendEmail: 'Resend Email',
successMessage: "We've sent a password reset link to {email}. Please check your inbox.",
emailRequired: 'Email is required',
sendFailed: 'Failed to send reset email'
sendFailed: 'Failed to send reset email',
};
interface Props {
@ -70,7 +70,7 @@
lightBackground = '#f5f5f5',
darkBackground = '#121212',
appSlider,
translations = {}
translations = {},
}: Props = $props();
// Merge provided translations with defaults
@ -142,7 +142,9 @@
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark
? '#000'
: '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
@ -157,7 +159,11 @@
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
style="background-color: {isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<h2
@ -197,7 +203,13 @@
placeholder={t.emailPlaceholder}
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
@ -205,7 +217,9 @@
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark
? '#ffffff'
: '#000000'};"
>
<Icon name="key" size={20} />
{loading ? t.sending : t.sendResetLinkButton}
@ -224,7 +238,7 @@
</button>
</div>
<!-- Success Mode -->
<!-- Success Mode -->
{:else}
<div class="pb-4">
<div class="flex flex-col items-center mb-6">
@ -239,7 +253,10 @@
class="text-sm text-center px-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
{@html getSuccessMessage(resetEmail).replace(resetEmail, `<strong>${resetEmail}</strong>`)}
{@html getSuccessMessage(resetEmail).replace(
resetEmail,
`<strong>${resetEmail}</strong>`
)}
</p>
</div>
@ -247,7 +264,9 @@
<button
onclick={() => goto(loginPath)}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark
? '#ffffff'
: '#000000'};"
>
<Icon name="sign-in" size={20} />
{t.backToLogin}
@ -259,7 +278,11 @@
error = null;
}}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
style="background-color: {isDark
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
{t.resendEmail}
</button>

View file

@ -56,7 +56,7 @@
signInFailed: 'Sign in failed',
googleSignInFailed: 'Google sign in failed',
signInSuccess: 'Successfully signed in. Redirecting...',
googleSignInSuccess: 'Successfully signed in with Google. Redirecting...'
googleSignInSuccess: 'Successfully signed in with Google. Redirecting...',
};
interface Props {
@ -113,7 +113,7 @@
darkBackground = '#121212',
appSlider,
headerControls,
translations = {}
translations = {},
}: Props = $props();
// Merge provided translations with defaults
@ -259,11 +259,311 @@
<title>Login - {appName}</title>
</svelte:head>
<!-- Skip Link for keyboard users -->
<button class="skip-link" onclick={skipToForm} type="button">
{t.skipToForm}
</button>
<!-- Screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{successAnnouncement}
</div>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Header Controls (Theme Toggle, Language Selector, etc.) -->
{#if headerControls}
<div class="absolute right-4 top-4 z-50 flex items-center gap-3 opacity-60">
{@render headerControls()}
</div>
{/if}
<main>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
class:success-pulse={showSuccess}
style="width: 120px; height: 120px; border: 3px solid {showSuccess
? '#22c55e'
: primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
role="img"
aria-label="{appName} logo"
>
{#if showSuccess}
<Icon name="check" size={55} color="#22c55e" />
{:else}
<Logo size={55} color={primaryColor} />
{/if}
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Auth Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
class:shake={shakeError}
style="background-color: {isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2
class="text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{t.title}
</h2>
<p
class="mt-2 text-sm text-center"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{t.subtitle}
</p>
</div>
<!-- Error Messages -->
{#if error}
<div
id="form-error"
role="alert"
aria-live="assertive"
class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3 flex items-center gap-2"
>
<Icon name="warning" size={18} color="#ef4444" />
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Login Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="pb-4"
aria-busy={loading}
aria-describedby={error ? 'form-error' : undefined}
>
<!-- Email Field -->
<div class="mb-3">
<label for="email" class="sr-only">{t.emailPlaceholder}</label>
<input
id="email"
type="email"
bind:this={emailInput}
bind:value={email}
placeholder={t.emailPlaceholder}
required
autocomplete="email"
aria-invalid={errorField === 'email'}
aria-describedby={errorField === 'email' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'email'
? '#ef4444'
: isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {errorField === 'email' ? '#ef4444' : primaryColor};"
/>
</div>
<!-- Password Field -->
<div class="mb-3 relative">
<label for="password" class="sr-only">{t.passwordPlaceholder}</label>
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:this={passwordInput}
bind:value={password}
placeholder={t.passwordPlaceholder}
required
autocomplete="current-password"
aria-invalid={errorField === 'password'}
aria-describedby={errorField === 'password' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'password'
? '#ef4444'
: isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {errorField === 'password'
? '#ef4444'
: primaryColor};"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors touch-target flex items-center justify-center"
aria-label={showPassword ? t.hidePassword : t.showPassword}
aria-pressed={showPassword}
title={showPassword ? t.hidePassword : t.showPassword}
>
<Icon
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
/>
</button>
</div>
<!-- Remember Me & Forgot Password Row -->
<div class="mb-4 flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer touch-target">
<input
type="checkbox"
bind:checked={rememberMe}
class="w-5 h-5 rounded border-2 transition-colors cursor-pointer"
style="accent-color: {primaryColor};"
/>
<span
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
{t.rememberMe}
</span>
</label>
<button
type="button"
onclick={() => goto(forgotPasswordPath)}
class="text-sm font-medium transition-opacity hover:opacity-70 touch-target flex items-center justify-center px-2"
style="color: {primaryColor};"
>
{t.forgotPassword}
</button>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading || showSuccess}
aria-disabled={loading || showSuccess}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2 touch-target"
style="background-color: {showSuccess
? '#22c55e'
: primaryColor + '60'}; border-color: {showSuccess
? '#22c55e'
: primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
{#if loading}
<svg
class="spinner w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
<span>{t.signingIn}</span>
{:else if showSuccess}
<Icon name="check" size={20} />
<span>{t.success}</span>
{:else}
<Icon name="sign-in" size={20} />
<span>{t.signInButton}</span>
{/if}
</button>
</form>
<!-- Social Login -->
{#if enableGoogle || enableApple}
<div class="my-4 flex items-center gap-3" role="separator" aria-orientation="horizontal">
<div
class="flex-1 border-t"
style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
></div>
<span
class="text-xs"
style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};"
>{t.orDivider}</span
>
<div
class="flex-1 border-t"
style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
></div>
</div>
<div class="mb-4 flex flex-col gap-2" role="group" aria-label="Social login options">
{#if enableGoogle && onSignInWithGoogle}
<GoogleSignInButton onSuccess={handleGoogleSuccess} />
{/if}
{#if enableApple}
<AppleSignInButton />
{/if}
</div>
{/if}
<!-- Register Link -->
<div class="mt-4 text-center">
<p
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{t.noAccount}
<button
type="button"
onclick={() => goto(registerPath)}
class="font-medium transition-opacity hover:opacity-70 touch-target inline-flex items-center justify-center px-1"
style="color: {primaryColor};"
>
{t.createAccount}
</button>
</p>
</div>
</div>
</div>
</main>
<!-- App Slider -->
{#if appSlider}
<div class="w-full pb-8 px-2 pt-4">
{@render appSlider()}
</div>
{:else}
<!-- Bottom padding -->
<div class="pb-8"></div>
{/if}
</div>
<style>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}
.shake {
@ -271,8 +571,12 @@
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinner {
@ -280,9 +584,18 @@
}
@keyframes success-pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.9; }
100% { transform: scale(1); opacity: 1; }
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.success-pulse {
@ -338,255 +651,3 @@
min-height: 44px;
}
</style>
<!-- Skip Link for keyboard users -->
<button
class="skip-link"
onclick={skipToForm}
type="button"
>
{t.skipToForm}
</button>
<!-- Screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{successAnnouncement}
</div>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Header Controls (Theme Toggle, Language Selector, etc.) -->
{#if headerControls}
<div class="absolute right-4 top-4 z-50 flex items-center gap-3 opacity-60">
{@render headerControls()}
</div>
{/if}
<main>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
class:success-pulse={showSuccess}
style="width: 120px; height: 120px; border: 3px solid {showSuccess ? '#22c55e' : primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
role="img"
aria-label="{appName} logo"
>
{#if showSuccess}
<Icon name="check" size={55} color="#22c55e" />
{:else}
<Logo size={55} color={primaryColor} />
{/if}
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Auth Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
class:shake={shakeError}
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2
class="text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{t.title}
</h2>
<p
class="mt-2 text-sm text-center"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{t.subtitle}
</p>
</div>
<!-- Error Messages -->
{#if error}
<div
id="form-error"
role="alert"
aria-live="assertive"
class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3 flex items-center gap-2"
>
<Icon name="warning" size={18} color="#ef4444" />
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Login Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="pb-4"
aria-busy={loading}
aria-describedby={error ? 'form-error' : undefined}
>
<!-- Email Field -->
<div class="mb-3">
<label for="email" class="sr-only">{t.emailPlaceholder}</label>
<input
id="email"
type="email"
bind:this={emailInput}
bind:value={email}
placeholder={t.emailPlaceholder}
required
autocomplete="email"
aria-invalid={errorField === 'email'}
aria-describedby={errorField === 'email' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'email' ? '#ef4444' : (isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)')}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {errorField === 'email' ? '#ef4444' : primaryColor};"
/>
</div>
<!-- Password Field -->
<div class="mb-3 relative">
<label for="password" class="sr-only">{t.passwordPlaceholder}</label>
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:this={passwordInput}
bind:value={password}
placeholder={t.passwordPlaceholder}
required
autocomplete="current-password"
aria-invalid={errorField === 'password'}
aria-describedby={errorField === 'password' ? 'form-error' : undefined}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {errorField === 'password' ? '#ef4444' : (isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)')}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {errorField === 'password' ? '#ef4444' : primaryColor};"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors touch-target flex items-center justify-center"
aria-label={showPassword ? t.hidePassword : t.showPassword}
aria-pressed={showPassword}
title={showPassword ? t.hidePassword : t.showPassword}
>
<Icon
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
/>
</button>
</div>
<!-- Remember Me & Forgot Password Row -->
<div class="mb-4 flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer touch-target">
<input
type="checkbox"
bind:checked={rememberMe}
class="w-5 h-5 rounded border-2 transition-colors cursor-pointer"
style="accent-color: {primaryColor};"
/>
<span
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
{t.rememberMe}
</span>
</label>
<button
type="button"
onclick={() => goto(forgotPasswordPath)}
class="text-sm font-medium transition-opacity hover:opacity-70 touch-target flex items-center justify-center px-2"
style="color: {primaryColor};"
>
{t.forgotPassword}
</button>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading || showSuccess}
aria-disabled={loading || showSuccess}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2 touch-target"
style="background-color: {showSuccess ? '#22c55e' : primaryColor + '60'}; border-color: {showSuccess ? '#22c55e' : primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
{#if loading}
<svg
class="spinner w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
<span>{t.signingIn}</span>
{:else if showSuccess}
<Icon name="check" size={20} />
<span>{t.success}</span>
{:else}
<Icon name="sign-in" size={20} />
<span>{t.signInButton}</span>
{/if}
</button>
</form>
<!-- Social Login -->
{#if enableGoogle || enableApple}
<div class="my-4 flex items-center gap-3" role="separator" aria-orientation="horizontal">
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
<span class="text-xs" style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};">{t.orDivider}</span>
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
</div>
<div class="mb-4 flex flex-col gap-2" role="group" aria-label="Social login options">
{#if enableGoogle && onSignInWithGoogle}
<GoogleSignInButton onSuccess={handleGoogleSuccess} />
{/if}
{#if enableApple}
<AppleSignInButton />
{/if}
</div>
{/if}
<!-- Register Link -->
<div class="mt-4 text-center">
<p
class="text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{t.noAccount}
<button
type="button"
onclick={() => goto(registerPath)}
class="font-medium transition-opacity hover:opacity-70 touch-target inline-flex items-center justify-center px-1"
style="color: {primaryColor};"
>
{t.createAccount}
</button>
</p>
</div>
</div>
</div>
</main>
<!-- App Slider -->
{#if appSlider}
<div class="w-full pb-8 px-2 pt-4">
{@render appSlider()}
</div>
{:else}
<!-- Bottom padding -->
<div class="pb-8"></div>
{/if}
</div>

View file

@ -35,7 +35,8 @@
emailPlaceholder: 'Email',
passwordPlaceholder: 'Password',
confirmPasswordPlaceholder: 'Confirm Password',
passwordRequirements: 'Password must be at least 8 characters with lowercase, uppercase, number, and special character.',
passwordRequirements:
'Password must be at least 8 characters with lowercase, uppercase, number, and special character.',
createAccountButton: 'Create Account',
creatingAccount: 'Creating Account...',
backToLogin: 'Back to Login',
@ -46,9 +47,10 @@
confirmPasswordRequired: 'Please confirm your password',
passwordsDoNotMatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 8 characters',
passwordStrengthError: 'Password must include lowercase, uppercase, number, and special character',
passwordStrengthError:
'Password must include lowercase, uppercase, number, and special character',
registrationFailed: 'Registration failed',
accountCreated: 'Account created! Please check your email to verify your account.'
accountCreated: 'Account created! Please check your email to verify your account.',
};
interface Props {
@ -87,7 +89,7 @@
lightBackground = '#f5f5f5',
darkBackground = '#121212',
appSlider,
translations = {}
translations = {},
}: Props = $props();
// Merge provided translations with defaults
@ -126,7 +128,7 @@
lowercase: false,
uppercase: false,
digit: false,
special: false
special: false,
};
}
@ -135,7 +137,7 @@
lowercase: /[a-z]/.test(password),
uppercase: /[A-Z]/.test(password),
digit: /[0-9]/.test(password),
special: /[^a-zA-Z0-9]/.test(password)
special: /[^a-zA-Z0-9]/.test(password),
};
});
@ -222,7 +224,9 @@
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark
? '#000'
: '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
@ -237,7 +241,11 @@
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
style="background-color: {isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<h2
@ -278,7 +286,13 @@
placeholder={t.emailPlaceholder}
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
@ -290,7 +304,13 @@
required
minlength={8}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {primaryColor};"
/>
<button
type="button"
@ -314,7 +334,13 @@
required
minlength={8}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'}; color: {isDark
? '#ffffff'
: '#000000'}; --tw-ring-color: {primaryColor};"
/>
<button
type="button"
@ -342,7 +368,9 @@
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark
? '#ffffff'
: '#000000'};"
>
<Icon name="user-plus" size={20} />
{loading ? t.creatingAccount : t.createAccountButton}

View file

@ -4,77 +4,77 @@ import type { Component } from 'svelte';
* Configuration for auth UI pages
*/
export interface AuthUIConfig {
/** App name to display */
appName: string;
/** App name to display */
appName: string;
/** Logo component to render */
logo: Component<{ size?: number; color?: string }>;
/** Logo component to render */
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Primary color (hex) */
primaryColor: string;
/** Primary color for dark mode (optional, defaults to primaryColor) */
darkPrimaryColor?: string;
/** Primary color for dark mode (optional, defaults to primaryColor) */
darkPrimaryColor?: string;
/** Page background color for light mode */
lightBackground?: string;
/** Page background color for light mode */
lightBackground?: string;
/** Page background color for dark mode */
darkBackground?: string;
/** Page background color for dark mode */
darkBackground?: string;
/** Redirect path after successful login (default: '/dashboard') */
successRedirect?: string;
/** Redirect path after successful login (default: '/dashboard') */
successRedirect?: string;
/** Enable Google Sign-In */
enableGoogle?: boolean;
/** Enable Google Sign-In */
enableGoogle?: boolean;
/** Enable Apple Sign-In */
enableApple?: boolean;
/** Enable Apple Sign-In */
enableApple?: boolean;
/** Google OAuth Client ID (required if enableGoogle is true) */
googleClientId?: string;
/** Google OAuth Client ID (required if enableGoogle is true) */
googleClientId?: string;
/** Apple OAuth Service ID (required if enableApple is true) */
appleClientId?: string;
/** Apple OAuth Service ID (required if enableApple is true) */
appleClientId?: string;
/** Apple OAuth Redirect URI */
appleRedirectUri?: string;
/** Apple OAuth Redirect URI */
appleRedirectUri?: string;
}
/**
* Auth service interface expected by the UI components
*/
export interface AuthServiceInterface {
signIn(email: string, password: string): Promise<AuthResult>;
signUp(email: string, password: string): Promise<AuthResult>;
signInWithGoogle?(idToken: string): Promise<AuthResult>;
signInWithApple?(identityToken: string): Promise<AuthResult>;
forgotPassword(email: string): Promise<AuthResult>;
signIn(email: string, password: string): Promise<AuthResult>;
signUp(email: string, password: string): Promise<AuthResult>;
signInWithGoogle?(idToken: string): Promise<AuthResult>;
signInWithApple?(identityToken: string): Promise<AuthResult>;
forgotPassword(email: string): Promise<AuthResult>;
}
/**
* Result from auth operations
*/
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
success: boolean;
error?: string;
needsVerification?: boolean;
}
/**
* Icon names available in the icon set
*/
export type IconName =
| 'user-plus'
| 'sign-in'
| 'eye'
| 'eye-off'
| 'key'
| 'arrow-left'
| 'info'
| 'mail-open'
| 'lock'
| 'shield-check'
| 'arrows-left-right'
| 'envelope'
| 'folder';
| 'user-plus'
| 'sign-in'
| 'eye'
| 'eye-off'
| 'key'
| 'arrow-left'
| 'info'
| 'mail-open'
| 'lock'
| 'shield-check'
| 'arrows-left-right'
| 'envelope'
| 'folder';

View file

@ -5,47 +5,47 @@
// TypeScript definitions for Apple ID SDK
declare global {
interface Window {
AppleID?: {
auth: {
init: (config: AppleIDInitConfig) => void;
signIn: () => Promise<AppleIDSignInResponse>;
};
};
}
interface Window {
AppleID?: {
auth: {
init: (config: AppleIDInitConfig) => void;
signIn: () => Promise<AppleIDSignInResponse>;
};
};
}
}
interface AppleIDInitConfig {
clientId: string;
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
usePopup?: boolean;
responseType?: string;
responseMode?: string;
clientId: string;
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
usePopup?: boolean;
responseType?: string;
responseMode?: string;
}
interface AppleIDSignInResponse {
authorization: {
code: string;
id_token?: string;
state?: string;
};
user?: {
email?: string;
name?: {
firstName?: string;
lastName?: string;
};
};
authorization: {
code: string;
id_token?: string;
state?: string;
};
user?: {
email?: string;
name?: {
firstName?: string;
lastName?: string;
};
};
}
export interface AppleAuthorizationResponse {
code: string;
id_token?: string;
state?: string;
user?: string;
code: string;
id_token?: string;
state?: string;
user?: string;
}
let appleClientId: string | null = null;
@ -55,162 +55,162 @@ let appleRedirectUri: string | null = null;
* Set Apple Sign-In configuration
*/
export function setAppleConfig(clientId: string, redirectUri: string) {
appleClientId = clientId;
appleRedirectUri = redirectUri;
appleClientId = clientId;
appleRedirectUri = redirectUri;
}
/**
* Check if running in browser
*/
function isBrowser(): boolean {
return typeof window !== 'undefined';
return typeof window !== 'undefined';
}
/**
* Initialize Apple ID SDK
*/
export function initializeAppleAuth(): boolean {
if (!isBrowser() || !window.AppleID) {
console.warn('Apple ID SDK not loaded');
return false;
}
if (!isBrowser() || !window.AppleID) {
console.warn('Apple ID SDK not loaded');
return false;
}
if (!appleClientId || !appleRedirectUri) {
console.error('Apple Sign-In not configured. Call setAppleConfig() first.');
return false;
}
if (!appleClientId || !appleRedirectUri) {
console.error('Apple Sign-In not configured. Call setAppleConfig() first.');
return false;
}
try {
window.AppleID.auth.init({
clientId: appleClientId,
scope: 'name email',
redirectURI: appleRedirectUri,
state: generateState(),
usePopup: false,
responseType: 'code id_token',
responseMode: 'form_post'
});
try {
window.AppleID.auth.init({
clientId: appleClientId,
scope: 'name email',
redirectURI: appleRedirectUri,
state: generateState(),
usePopup: false,
responseType: 'code id_token',
responseMode: 'form_post',
});
console.log('Apple ID SDK initialized successfully');
return true;
} catch (error) {
console.error('Error initializing Apple ID SDK:', error);
return false;
}
console.log('Apple ID SDK initialized successfully');
return true;
} catch (error) {
console.error('Error initializing Apple ID SDK:', error);
return false;
}
}
/**
* Initiate Apple Sign-In (redirect flow)
*/
export async function signInWithApple(): Promise<void> {
if (!isBrowser()) {
throw new Error('Apple Sign-In only available in browser');
}
if (!isBrowser()) {
throw new Error('Apple Sign-In only available in browser');
}
if (!window.AppleID) {
throw new Error('Apple ID SDK not loaded');
}
if (!window.AppleID) {
throw new Error('Apple ID SDK not loaded');
}
try {
const returnTo = window.location.pathname + window.location.search;
sessionStorage.setItem('apple_signin_return_to', returnTo);
await window.AppleID.auth.signIn();
} catch (error) {
console.error('Error initiating Apple Sign-In:', error);
throw error;
}
try {
const returnTo = window.location.pathname + window.location.search;
sessionStorage.setItem('apple_signin_return_to', returnTo);
await window.AppleID.auth.signIn();
} catch (error) {
console.error('Error initiating Apple Sign-In:', error);
throw error;
}
}
/**
* Parse Apple authorization response from URL
*/
export function parseAppleAuthorizationResponse(
urlParams: URLSearchParams
urlParams: URLSearchParams
): AppleAuthorizationResponse | null {
const code = urlParams.get('code');
const id_token = urlParams.get('id_token');
const state = urlParams.get('state');
const user = urlParams.get('user');
const error = urlParams.get('error');
const code = urlParams.get('code');
const id_token = urlParams.get('id_token');
const state = urlParams.get('state');
const user = urlParams.get('user');
const error = urlParams.get('error');
if (error) {
console.error('Apple Sign-In error:', error);
return null;
}
if (error) {
console.error('Apple Sign-In error:', error);
return null;
}
const storedState = sessionStorage.getItem('apple_signin_state');
if (state !== storedState) {
console.error('State mismatch - possible CSRF attack');
return null;
}
const storedState = sessionStorage.getItem('apple_signin_state');
if (state !== storedState) {
console.error('State mismatch - possible CSRF attack');
return null;
}
if (!id_token && !code) {
console.error('No id_token or authorization code in Apple response');
return null;
}
if (!id_token && !code) {
console.error('No id_token or authorization code in Apple response');
return null;
}
return {
code: code || '',
id_token: id_token || undefined,
state: state || undefined,
user: user || undefined
};
return {
code: code || '',
id_token: id_token || undefined,
state: state || undefined,
user: user || undefined,
};
}
/**
* Generate random state for CSRF protection
*/
function generateState(): string {
const state = Math.random().toString(36).substring(2, 15);
if (isBrowser()) {
sessionStorage.setItem('apple_signin_state', state);
}
return state;
const state = Math.random().toString(36).substring(2, 15);
if (isBrowser()) {
sessionStorage.setItem('apple_signin_state', state);
}
return state;
}
/**
* Get stored return URL
*/
export function getStoredReturnUrl(): string {
if (!isBrowser()) return '/dashboard';
return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
if (!isBrowser()) return '/dashboard';
return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
}
/**
* Clear Apple Sign-In session data
*/
export function clearAppleSignInSession() {
if (!isBrowser()) return;
sessionStorage.removeItem('apple_signin_state');
sessionStorage.removeItem('apple_signin_return_to');
if (!isBrowser()) return;
sessionStorage.removeItem('apple_signin_state');
sessionStorage.removeItem('apple_signin_return_to');
}
/**
* Check if Apple ID SDK is loaded
*/
export function isAppleAuthLoaded(): boolean {
return isBrowser() && !!window.AppleID?.auth;
return isBrowser() && !!window.AppleID?.auth;
}
/**
* Wait for Apple ID SDK to load
*/
export function waitForAppleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isAppleAuthLoaded()) {
resolve();
return;
}
return new Promise((resolve, reject) => {
if (isAppleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isAppleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Apple ID SDK failed to load'));
}
}, 100);
});
const startTime = Date.now();
const interval = setInterval(() => {
if (isAppleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Apple ID SDK failed to load'));
}
}, 100);
});
}

View file

@ -5,68 +5,68 @@
// TypeScript definitions for Google Identity Services
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: GoogleIdConfiguration) => void;
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
storeCredential: (credential: { id: string; password: string }) => void;
cancel: () => void;
onGoogleLibraryLoad: () => void;
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
};
};
};
}
interface Window {
google?: {
accounts: {
id: {
initialize: (config: GoogleIdConfiguration) => void;
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
storeCredential: (credential: { id: string; password: string }) => void;
cancel: () => void;
onGoogleLibraryLoad: () => void;
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
};
};
};
}
}
interface GoogleIdConfiguration {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
login_uri?: string;
native_callback?: (response: { id: string; password: string }) => void;
itp_support?: boolean;
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
login_uri?: string;
native_callback?: (response: { id: string; password: string }) => void;
itp_support?: boolean;
}
interface CredentialResponse {
credential: string;
select_by: string;
clientId?: string;
credential: string;
select_by: string;
clientId?: string;
}
interface GsiButtonConfiguration {
type?: 'standard' | 'icon';
theme?: 'outline' | 'filled_blue' | 'filled_black';
size?: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
logo_alignment?: 'left' | 'center';
width?: string;
locale?: string;
type?: 'standard' | 'icon';
theme?: 'outline' | 'filled_blue' | 'filled_black';
size?: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
logo_alignment?: 'left' | 'center';
width?: string;
locale?: string;
}
interface PromptMomentNotification {
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () => string;
isSkippedMoment: () => boolean;
getSkippedReason: () => string;
isDismissedMoment: () => boolean;
getDismissedReason: () => string;
getMomentType: () => 'display' | 'skipped' | 'dismissed';
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () => string;
isSkippedMoment: () => boolean;
getSkippedReason: () => string;
isDismissedMoment: () => boolean;
getDismissedReason: () => string;
getMomentType: () => 'display' | 'skipped' | 'dismissed';
}
interface RevocationResponse {
successful: boolean;
error?: string;
successful: boolean;
error?: string;
}
let googleClientId: string | null = null;
@ -75,100 +75,100 @@ let googleClientId: string | null = null;
* Set Google Client ID for initialization
*/
export function setGoogleClientId(clientId: string) {
googleClientId = clientId;
googleClientId = clientId;
}
/**
* Initialize Google Identity Services
*/
export function initializeGoogleAuth(callback: (idToken: string) => void) {
if (typeof window === 'undefined') {
console.warn('Google Auth: Cannot initialize on server-side');
return;
}
if (typeof window === 'undefined') {
console.warn('Google Auth: Cannot initialize on server-side');
return;
}
if (!window.google) {
console.warn('Google Identity Services not loaded yet');
return;
}
if (!window.google) {
console.warn('Google Identity Services not loaded yet');
return;
}
if (!googleClientId) {
console.error('Google Client ID not configured. Call setGoogleClientId() first.');
return;
}
if (!googleClientId) {
console.error('Google Client ID not configured. Call setGoogleClientId() first.');
return;
}
try {
window.google.accounts.id.initialize({
client_id: googleClientId,
callback: (response: CredentialResponse) => {
callback(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
ux_mode: 'popup'
});
} catch (error) {
console.error('Error initializing Google Auth:', error);
}
try {
window.google.accounts.id.initialize({
client_id: googleClientId,
callback: (response: CredentialResponse) => {
callback(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
ux_mode: 'popup',
});
} catch (error) {
console.error('Error initializing Google Auth:', error);
}
}
/**
* Render Google Sign-In button
*/
export function renderGoogleButton(
element: HTMLElement,
options?: Partial<GsiButtonConfiguration>
element: HTMLElement,
options?: Partial<GsiButtonConfiguration>
) {
if (typeof window === 'undefined' || !window.google) {
console.warn('Google Identity Services not available');
return;
}
if (typeof window === 'undefined' || !window.google) {
console.warn('Google Identity Services not available');
return;
}
const defaultOptions: GsiButtonConfiguration = {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'left'
};
const defaultOptions: GsiButtonConfiguration = {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'left',
};
try {
window.google.accounts.id.renderButton(element, {
...defaultOptions,
...options
});
} catch (error) {
console.error('Error rendering Google button:', error);
}
try {
window.google.accounts.id.renderButton(element, {
...defaultOptions,
...options,
});
} catch (error) {
console.error('Error rendering Google button:', error);
}
}
/**
* Check if Google Identity Services is loaded
*/
export function isGoogleAuthLoaded(): boolean {
return typeof window !== 'undefined' && !!window.google?.accounts?.id;
return typeof window !== 'undefined' && !!window.google?.accounts?.id;
}
/**
* Wait for Google Identity Services to load
*/
export function waitForGoogleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isGoogleAuthLoaded()) {
resolve();
return;
}
return new Promise((resolve, reject) => {
if (isGoogleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isGoogleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Google Identity Services failed to load'));
}
}, 100);
});
const startTime = Date.now();
const interval = setInterval(() => {
if (isGoogleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Google Identity Services failed to load'));
}
}, 100);
});
}

View file

@ -1,19 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -24,13 +24,13 @@ pnpm add @manacore/shared-auth
import { initializeWebAuth } from '@manacore/shared-auth';
const { authService, tokenManager } = initializeWebAuth({
baseUrl: 'https://api.example.com',
baseUrl: 'https://api.example.com',
});
// Sign in
const result = await authService.signIn('user@example.com', 'password');
if (result.success) {
console.log('Signed in!');
console.log('Signed in!');
}
// Get current user
@ -45,32 +45,32 @@ await authService.signOut();
```typescript
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
setupFetchInterceptor,
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
setupFetchInterceptor,
} from '@manacore/shared-auth';
import * as SecureStore from 'expo-secure-store';
// Create storage adapter for Expo
const expoStorageAdapter = {
async getItem<T = string>(key: string): Promise<T | null> {
const value = await SecureStore.getItemAsync(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
},
async getItem<T = string>(key: string): Promise<T | null> {
const value = await SecureStore.getItemAsync(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
},
};
// Set up adapters
@ -80,7 +80,7 @@ setNetworkAdapter(yourNetworkAdapter);
// Create services
const authService = createAuthService({
baseUrl: process.env.EXPO_PUBLIC_API_URL,
baseUrl: process.env.EXPO_PUBLIC_API_URL,
});
const tokenManager = createTokenManager(authService);
@ -96,17 +96,17 @@ Creates an authentication service instance.
```typescript
const authService = createAuthService({
baseUrl: 'https://api.example.com',
storageKeys: {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
USER_EMAIL: '@auth/userEmail',
},
endpoints: {
signIn: '/auth/signin',
signUp: '/auth/signup',
// ... other endpoints
},
baseUrl: 'https://api.example.com',
storageKeys: {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
USER_EMAIL: '@auth/userEmail',
},
endpoints: {
signIn: '/auth/signin',
signUp: '/auth/signup',
// ... other endpoints
},
});
```
@ -116,15 +116,15 @@ Creates a token manager for handling token refresh and state.
```typescript
const tokenManager = createTokenManager(authService, {
maxQueueSize: 50,
queueTimeoutMs: 30000,
maxRefreshAttempts: 3,
refreshCooldownMs: 5000,
maxQueueSize: 50,
queueTimeoutMs: 30000,
maxRefreshAttempts: 3,
refreshCooldownMs: 5000,
});
// Subscribe to state changes
const unsubscribe = tokenManager.subscribe((state, token) => {
console.log('Token state:', state);
console.log('Token state:', state);
});
// Get valid token (refreshes if needed)
@ -135,11 +135,11 @@ const token = await tokenManager.getValidToken();
```typescript
import {
decodeToken,
isTokenValidLocally,
getUserFromToken,
isB2BUser,
getB2BInfo,
decodeToken,
isTokenValidLocally,
getUserFromToken,
isB2BUser,
getB2BInfo,
} from '@manacore/shared-auth';
const payload = decodeToken(token);
@ -157,11 +157,7 @@ The package uses adapters for platform-specific functionality:
- **NetworkAdapter**: For checking network connectivity
```typescript
import {
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
} from '@manacore/shared-auth';
import { setStorageAdapter, setDeviceAdapter, setNetworkAdapter } from '@manacore/shared-auth';
setStorageAdapter(myStorageAdapter);
setDeviceAdapter(myDeviceAdapter);

View file

@ -1,37 +1,37 @@
{
"name": "@manacore/shared-auth",
"version": "0.1.0",
"description": "Shared authentication utilities for Manacore apps",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"dependencies": {
"base64-js": "^1.5.1"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"peerDependencies": {
"react-native": ">=0.70.0"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
},
"keywords": [
"manacore",
"auth",
"jwt",
"token"
],
"license": "MIT"
"name": "@manacore/shared-auth",
"version": "0.1.0",
"description": "Shared authentication utilities for Manacore apps",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"dependencies": {
"base64-js": "^1.5.1"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"peerDependencies": {
"react-native": ">=0.70.0"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
},
"keywords": [
"manacore",
"auth",
"jwt",
"token"
],
"license": "MIT"
}

View file

@ -6,76 +6,76 @@ let deviceAdapter: DeviceManagerAdapter | null = null;
* Set the device manager adapter for the auth service
*/
export function setDeviceAdapter(adapter: DeviceManagerAdapter): void {
deviceAdapter = adapter;
deviceAdapter = adapter;
}
/**
* Get the current device adapter
*/
export function getDeviceAdapter(): DeviceManagerAdapter {
if (!deviceAdapter) {
throw new Error(
'Device adapter not initialized. Call setDeviceAdapter() before using auth services.'
);
}
return deviceAdapter;
if (!deviceAdapter) {
throw new Error(
'Device adapter not initialized. Call setDeviceAdapter() before using auth services.'
);
}
return deviceAdapter;
}
/**
* Check if device adapter is initialized
*/
export function isDeviceInitialized(): boolean {
return deviceAdapter !== null;
return deviceAdapter !== null;
}
/**
* Create a web-based device manager adapter
*/
export function createWebDeviceAdapter(): DeviceManagerAdapter {
// Generate a persistent device ID for web
const getOrCreateDeviceId = (): string => {
const storageKey = '@manacore/deviceId';
let deviceId = localStorage.getItem(storageKey);
if (!deviceId) {
deviceId = crypto.randomUUID();
localStorage.setItem(storageKey, deviceId);
}
return deviceId;
};
// Generate a persistent device ID for web
const getOrCreateDeviceId = (): string => {
const storageKey = '@manacore/deviceId';
let deviceId = localStorage.getItem(storageKey);
if (!deviceId) {
deviceId = crypto.randomUUID();
localStorage.setItem(storageKey, deviceId);
}
return deviceId;
};
return {
async getDeviceInfo(): Promise<DeviceInfo> {
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
let deviceType = 'web';
return {
async getDeviceInfo(): Promise<DeviceInfo> {
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
let deviceType = 'web';
// Try to extract browser name
if (userAgent.includes('Chrome')) {
deviceName = 'Chrome Browser';
} else if (userAgent.includes('Safari')) {
deviceName = 'Safari Browser';
} else if (userAgent.includes('Firefox')) {
deviceName = 'Firefox Browser';
} else if (userAgent.includes('Edge')) {
deviceName = 'Edge Browser';
}
// Try to extract browser name
if (userAgent.includes('Chrome')) {
deviceName = 'Chrome Browser';
} else if (userAgent.includes('Safari')) {
deviceName = 'Safari Browser';
} else if (userAgent.includes('Firefox')) {
deviceName = 'Firefox Browser';
} else if (userAgent.includes('Edge')) {
deviceName = 'Edge Browser';
}
// Detect device type
if (/Mobi|Android/i.test(userAgent)) {
deviceType = 'mobile_web';
} else if (/Tablet|iPad/i.test(userAgent)) {
deviceType = 'tablet_web';
}
// Detect device type
if (/Mobi|Android/i.test(userAgent)) {
deviceType = 'mobile_web';
} else if (/Tablet|iPad/i.test(userAgent)) {
deviceType = 'tablet_web';
}
return {
deviceId: getOrCreateDeviceId(),
deviceName,
deviceType,
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
return localStorage.getItem('@manacore/deviceId');
},
};
return {
deviceId: getOrCreateDeviceId(),
deviceName,
deviceType,
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
return localStorage.getItem('@manacore/deviceId');
},
};
}

View file

@ -6,50 +6,50 @@ let networkAdapter: NetworkAdapter | null = null;
* Set the network adapter for the auth service
*/
export function setNetworkAdapter(adapter: NetworkAdapter): void {
networkAdapter = adapter;
networkAdapter = adapter;
}
/**
* Get the current network adapter
*/
export function getNetworkAdapter(): NetworkAdapter | null {
return networkAdapter;
return networkAdapter;
}
/**
* Check if device is connected to the network
*/
export async function isDeviceConnected(): Promise<boolean> {
if (!networkAdapter) {
// Default to true if no adapter is set
return true;
}
return networkAdapter.isDeviceConnected();
if (!networkAdapter) {
// Default to true if no adapter is set
return true;
}
return networkAdapter.isDeviceConnected();
}
/**
* Check if device has a stable connection
*/
export async function hasStableConnection(): Promise<boolean> {
if (!networkAdapter || !networkAdapter.hasStableConnection) {
// Default to basic connectivity check
return isDeviceConnected();
}
return networkAdapter.hasStableConnection();
if (!networkAdapter || !networkAdapter.hasStableConnection) {
// Default to basic connectivity check
return isDeviceConnected();
}
return networkAdapter.hasStableConnection();
}
/**
* Create a web-based network adapter
*/
export function createWebNetworkAdapter(): NetworkAdapter {
return {
async isDeviceConnected(): Promise<boolean> {
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
// For web, we just check online status
// More sophisticated checks could be added
return navigator.onLine;
},
};
return {
async isDeviceConnected(): Promise<boolean> {
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
// For web, we just check online status
// More sophisticated checks could be added
return navigator.onLine;
},
};
}

View file

@ -18,72 +18,72 @@ let storageAdapter: StorageAdapter | null = null;
* Set the storage adapter for the auth service
*/
export function setStorageAdapter(adapter: StorageAdapter): void {
storageAdapter = adapter;
storageAdapter = adapter;
}
/**
* Get the current storage adapter
*/
export function getStorageAdapter(): StorageAdapter {
if (!storageAdapter) {
throw new Error(
'Storage adapter not initialized. Call setStorageAdapter() before using auth services.'
);
}
return storageAdapter;
if (!storageAdapter) {
throw new Error(
'Storage adapter not initialized. Call setStorageAdapter() before using auth services.'
);
}
return storageAdapter;
}
/**
* Check if storage adapter is initialized
*/
export function isStorageInitialized(): boolean {
return storageAdapter !== null;
return storageAdapter !== null;
}
/**
* Create a localStorage-based storage adapter (for web)
*/
export function createLocalStorageAdapter(): StorageAdapter {
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = localStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
},
};
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = localStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
},
};
}
/**
* Create an in-memory storage adapter (for testing)
*/
export function createMemoryStorageAdapter(): StorageAdapter {
const storage = new Map<string, string>();
const storage = new Map<string, string>();
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = storage.get(key);
if (value === undefined) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
storage.set(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
storage.delete(key);
},
};
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = storage.get(key);
if (value === undefined) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
storage.set(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
storage.delete(key);
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -4,157 +4,157 @@ import type { DecodedToken, UserData } from '../types';
* Decode a JWT token payload
*/
export function decodeToken(token: string): DecodedToken | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padding = base64.length % 4;
const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
// Add padding if needed
const padding = base64.length % 4;
const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
// Decode base64 - atob is available in browsers, Node.js 16+, and React Native
const payload: DecodedToken = JSON.parse(atob(paddedBase64));
// Decode base64 - atob is available in browsers, Node.js 16+, and React Native
const payload: DecodedToken = JSON.parse(atob(paddedBase64));
return payload;
} catch (error) {
console.error('Error decoding JWT token:', error);
return null;
}
return payload;
} catch (error) {
console.error('Error decoding JWT token:', error);
return null;
}
}
/**
* Check if a token is valid locally (not expired)
*/
export function isTokenValidLocally(token: string, bufferSeconds: number = 10): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return false;
}
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return false;
}
const bufferTime = bufferSeconds * 1000;
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
const bufferTime = bufferSeconds * 1000;
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
return currentTime < expiryTime - bufferTime;
} catch (error) {
console.debug('Error validating token locally:', error);
return false;
}
return currentTime < expiryTime - bufferTime;
} catch (error) {
console.debug('Error validating token locally:', error);
return false;
}
}
/**
* Check if a token is expired
*/
export function isTokenExpired(token: string): boolean {
return !isTokenValidLocally(token, 0);
return !isTokenValidLocally(token, 0);
}
/**
* Extract user data from a JWT token
*/
export function getUserFromToken(token: string, storedEmail?: string): UserData | null {
try {
const payload = decodeToken(token);
if (!payload) {
return null;
}
try {
const payload = decodeToken(token);
if (!payload) {
return null;
}
// Get email from various sources
let email = payload.email || '';
if (!email && payload.user_metadata?.email) {
email = payload.user_metadata.email;
}
if (!email && storedEmail) {
email = storedEmail;
}
// Get email from various sources
let email = payload.email || '';
if (!email && payload.user_metadata?.email) {
email = payload.user_metadata.email;
}
if (!email && storedEmail) {
email = storedEmail;
}
return {
id: payload.sub,
email: email || 'user@example.com',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error extracting user from token:', error);
return null;
}
return {
id: payload.sub,
email: email || 'user@example.com',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error extracting user from token:', error);
return null;
}
}
/**
* Get token expiration time in milliseconds
*/
export function getTokenExpirationTime(token: string): number | null {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return null;
}
return payload.exp * 1000;
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return null;
}
return payload.exp * 1000;
}
/**
* Get time until token expiration in milliseconds
*/
export function getTimeUntilExpiration(token: string): number {
const expirationTime = getTokenExpirationTime(token);
if (!expirationTime) {
return 0;
}
return Math.max(0, expirationTime - Date.now());
const expirationTime = getTokenExpirationTime(token);
if (!expirationTime) {
return 0;
}
return Math.max(0, expirationTime - Date.now());
}
/**
* Check if user is B2B based on JWT claims
*/
export function isB2BUser(token: string): boolean {
const payload = decodeToken(token);
if (!payload) {
return false;
}
const payload = decodeToken(token);
if (!payload) {
return false;
}
// Handle different types for is_b2b
return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
// Handle different types for is_b2b
return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
}
/**
* Get B2B information from JWT claims
*/
export function getB2BInfo(token: string): {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
} | null {
const payload = decodeToken(token);
if (!payload?.app_settings?.b2b) {
return null;
}
const payload = decodeToken(token);
if (!payload?.app_settings?.b2b) {
return null;
}
const b2bSettings = payload.app_settings.b2b;
return {
disableRevenueCat: !!b2bSettings.disableRevenueCat,
organizationId: b2bSettings.organizationId,
plan: b2bSettings.plan,
role: b2bSettings.role,
};
const b2bSettings = payload.app_settings.b2b;
return {
disableRevenueCat: !!b2bSettings.disableRevenueCat,
organizationId: b2bSettings.organizationId,
plan: b2bSettings.plan,
role: b2bSettings.role,
};
}
/**
* Check if RevenueCat should be disabled for this token
*/
export function shouldDisableRevenueCat(token: string): boolean {
const b2bInfo = getB2BInfo(token);
return b2bInfo?.disableRevenueCat ?? false;
const b2bInfo = getB2BInfo(token);
return b2bInfo?.disableRevenueCat ?? false;
}
/**
* Get app settings from JWT claims
*/
export function getAppSettings(token: string): Record<string, unknown> | null {
const payload = decodeToken(token);
return payload?.app_settings || null;
const payload = decodeToken(token);
return payload?.app_settings || null;
}

View file

@ -1,8 +1,8 @@
import type {
TokenState,
TokenStateObserver,
QueuedRequest,
InternalTokenRefreshResult,
TokenState,
TokenStateObserver,
QueuedRequest,
InternalTokenRefreshResult,
} from '../types';
import { TokenState as TokenStateEnum } from '../types';
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
@ -12,450 +12,459 @@ import type { AuthService } from './authService';
* Configuration for the token manager
*/
export interface TokenManagerConfig {
maxQueueSize?: number;
queueTimeoutMs?: number;
maxRefreshAttempts?: number;
refreshCooldownMs?: number;
maxQueueSize?: number;
queueTimeoutMs?: number;
maxRefreshAttempts?: number;
refreshCooldownMs?: number;
}
/**
* Create a token manager instance
*/
export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
// Configuration
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
// State
let state: TokenState = TokenStateEnum.IDLE;
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
let requestQueue: QueuedRequest[] = [];
const observers = new Set<TokenStateObserver>();
let refreshAttempts = 0;
let lastRefreshTime = 0;
// Internal functions
function notifyObservers(newState: TokenState, token?: string): void {
observers.forEach((observer) => {
try {
observer(newState, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
function setState(newState: TokenState, token?: string): void {
if (state !== newState) {
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
state = newState;
notifyObservers(newState, token);
}
}
function removeFromQueue(requestId: string): void {
const index = requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
requestQueue.splice(index, 1);
}
}
function isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const networkErrors = [
'network', 'Network', 'fetch', 'connection', 'timeout',
'Failed to fetch', 'NetworkError', 'TypeError', 'ERR_NETWORK',
'ERR_INTERNET_DISCONNECTED', 'ECONNREFUSED', 'ENOTFOUND',
'ETIMEDOUT', 'Unable to resolve host', 'Request failed',
];
const authErrors = [
'401', '403', 'Unauthorized', 'Forbidden', 'Invalid token',
'Token expired', 'jwt expired', 'jwt malformed',
];
const errorString = `${error.message} ${error.name}`.toLowerCase();
const isNetworkError = networkErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
const isAuthError = authErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
return isNetworkError && !isAuthError;
}
async function handleRefreshFailure(): Promise<void> {
console.debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
try {
console.debug('TokenManager: Starting token refresh');
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Device offline, skipping refresh');
const currentToken = await authService.getAppToken();
if (currentToken) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
}
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
const isStable = await hasStableConnection();
if (!isStable) {
console.debug('TokenManager: Connection not stable yet, will retry');
return { success: false, error: 'unstable_connection' };
}
const refreshToken = await authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken } = refreshResult;
console.debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
console.debug('TokenManager: Token refresh failed:', error);
const isRecoverable = isRecoverableError(error);
if (!isRecoverable) {
await handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000];
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
if (retryDelays[attempt] > 0) {
console.debug(
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
);
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
}
const result = await performTokenRefresh();
if (result.success) {
return result;
}
// Non-retryable errors
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error?.includes('Device ID has changed')
) {
return result;
}
if (result.error === 'offline') {
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
if (result.error === 'unstable_connection') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
lastError = new Error(result.error || 'Token refresh failed');
if (attempt === retryDelays.length - 1) break;
} catch (error) {
lastError = error;
const isRecoverable = isRecoverableError(error);
if (!isRecoverable || attempt === retryDelays.length - 1) {
break;
}
}
}
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
async function processQueuedRequests(token: string): Promise<void> {
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
try {
const response = await retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
async function rejectQueuedRequests(error: string): Promise<void> {
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
async function retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
// Public API
const manager = {
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
observers.add(observer);
return () => observers.delete(observer);
},
/**
* Get current token state
*/
getState(): TokenState {
return state;
},
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
setState(TokenStateEnum.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
console.debug('TokenManager: No token available, skipping refresh');
setState(TokenStateEnum.EXPIRED);
return null;
}
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Token expired while offline');
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
if (refreshResult.shouldPreserveAuth) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
return null;
},
/**
* Handle 401 response
*/
async handle401Response(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
return manager.queueRequest(input, init);
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
},
/**
* Queue a request during token refresh
*/
async queueRequest(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
return new Promise((resolve, reject) => {
if (requestQueue.length >= MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
requestQueue.push(queueItem);
setTimeout(() => {
removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, QUEUE_TIMEOUT_MS);
});
},
/**
* Refresh the authentication token
*/
async refreshToken(): Promise<InternalTokenRefreshResult> {
const now = Date.now();
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
await handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (refreshPromise) {
return refreshPromise;
}
setState(TokenStateEnum.REFRESHING);
lastRefreshTime = now;
refreshPromise = performTokenRefreshWithRetry();
try {
const result = await refreshPromise;
if (result.success) {
refreshAttempts = 0;
setState(TokenStateEnum.VALID, result.token);
await processQueuedRequests(result.token!);
} else {
refreshAttempts++;
setState(TokenStateEnum.EXPIRED);
await rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
refreshPromise = null;
}
},
/**
* Reset the token manager state
*/
reset(): void {
state = TokenStateEnum.IDLE;
refreshPromise = null;
refreshAttempts = 0;
lastRefreshTime = 0;
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
},
/**
* Clear tokens and reset state
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
manager.reset();
} catch (error) {
console.debug('Error clearing tokens:', error);
manager.reset();
}
},
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: requestQueue.length,
state,
refreshAttempts,
};
},
/**
* Check initial token state
*/
async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
setState(TokenStateEnum.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
setState(TokenStateEnum.VALID, token);
} else {
setState(TokenStateEnum.EXPIRED);
}
} catch (error) {
console.debug('Error checking initial token state:', error);
setState(TokenStateEnum.EXPIRED);
}
},
};
// Initialize
manager.checkInitialState();
return manager;
// Configuration
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
// State
let state: TokenState = TokenStateEnum.IDLE;
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
let requestQueue: QueuedRequest[] = [];
const observers = new Set<TokenStateObserver>();
let refreshAttempts = 0;
let lastRefreshTime = 0;
// Internal functions
function notifyObservers(newState: TokenState, token?: string): void {
observers.forEach((observer) => {
try {
observer(newState, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
function setState(newState: TokenState, token?: string): void {
if (state !== newState) {
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
state = newState;
notifyObservers(newState, token);
}
}
function removeFromQueue(requestId: string): void {
const index = requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
requestQueue.splice(index, 1);
}
}
function isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const networkErrors = [
'network',
'Network',
'fetch',
'connection',
'timeout',
'Failed to fetch',
'NetworkError',
'TypeError',
'ERR_NETWORK',
'ERR_INTERNET_DISCONNECTED',
'ECONNREFUSED',
'ENOTFOUND',
'ETIMEDOUT',
'Unable to resolve host',
'Request failed',
];
const authErrors = [
'401',
'403',
'Unauthorized',
'Forbidden',
'Invalid token',
'Token expired',
'jwt expired',
'jwt malformed',
];
const errorString = `${error.message} ${error.name}`.toLowerCase();
const isNetworkError = networkErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
const isAuthError = authErrors.some((keyword) => errorString.includes(keyword.toLowerCase()));
return isNetworkError && !isAuthError;
}
async function handleRefreshFailure(): Promise<void> {
console.debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
try {
console.debug('TokenManager: Starting token refresh');
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Device offline, skipping refresh');
const currentToken = await authService.getAppToken();
if (currentToken) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
}
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
const isStable = await hasStableConnection();
if (!isStable) {
console.debug('TokenManager: Connection not stable yet, will retry');
return { success: false, error: 'unstable_connection' };
}
const refreshToken = await authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken } = refreshResult;
console.debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
console.debug('TokenManager: Token refresh failed:', error);
const isRecoverable = isRecoverableError(error);
if (!isRecoverable) {
await handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000];
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
if (retryDelays[attempt] > 0) {
console.debug(
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
);
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
}
const result = await performTokenRefresh();
if (result.success) {
return result;
}
// Non-retryable errors
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error?.includes('Device ID has changed')
) {
return result;
}
if (result.error === 'offline') {
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
if (result.error === 'unstable_connection') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
lastError = new Error(result.error || 'Token refresh failed');
if (attempt === retryDelays.length - 1) break;
} catch (error) {
lastError = error;
const isRecoverable = isRecoverableError(error);
if (!isRecoverable || attempt === retryDelays.length - 1) {
break;
}
}
}
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
async function processQueuedRequests(token: string): Promise<void> {
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
try {
const response = await retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
async function rejectQueuedRequests(error: string): Promise<void> {
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
async function retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
// Public API
const manager = {
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
observers.add(observer);
return () => observers.delete(observer);
},
/**
* Get current token state
*/
getState(): TokenState {
return state;
},
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
setState(TokenStateEnum.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
console.debug('TokenManager: No token available, skipping refresh');
setState(TokenStateEnum.EXPIRED);
return null;
}
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Token expired while offline');
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
if (refreshResult.shouldPreserveAuth) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
return null;
},
/**
* Handle 401 response
*/
async handle401Response(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
return manager.queueRequest(input, init);
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
},
/**
* Queue a request during token refresh
*/
async queueRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
if (requestQueue.length >= MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
requestQueue.push(queueItem);
setTimeout(() => {
removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, QUEUE_TIMEOUT_MS);
});
},
/**
* Refresh the authentication token
*/
async refreshToken(): Promise<InternalTokenRefreshResult> {
const now = Date.now();
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
await handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (refreshPromise) {
return refreshPromise;
}
setState(TokenStateEnum.REFRESHING);
lastRefreshTime = now;
refreshPromise = performTokenRefreshWithRetry();
try {
const result = await refreshPromise;
if (result.success) {
refreshAttempts = 0;
setState(TokenStateEnum.VALID, result.token);
await processQueuedRequests(result.token!);
} else {
refreshAttempts++;
setState(TokenStateEnum.EXPIRED);
await rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
refreshPromise = null;
}
},
/**
* Reset the token manager state
*/
reset(): void {
state = TokenStateEnum.IDLE;
refreshPromise = null;
refreshAttempts = 0;
lastRefreshTime = 0;
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
},
/**
* Clear tokens and reset state
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
manager.reset();
} catch (error) {
console.debug('Error clearing tokens:', error);
manager.reset();
}
},
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: requestQueue.length,
state,
refreshAttempts,
};
},
/**
* Check initial token state
*/
async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
setState(TokenStateEnum.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
setState(TokenStateEnum.VALID, token);
} else {
setState(TokenStateEnum.EXPIRED);
}
} catch (error) {
console.debug('Error checking initial token state:', error);
setState(TokenStateEnum.EXPIRED);
}
},
};
// Initialize
manager.checkInitialState();
return manager;
}
/**

View file

@ -11,62 +11,62 @@ export { createTokenManager } from './core/tokenManager';
export type { TokenManager, TokenManagerConfig } from './core/tokenManager';
export {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
getTokenExpirationTime,
getTimeUntilExpiration,
isB2BUser,
getB2BInfo,
shouldDisableRevenueCat,
getAppSettings,
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
getTokenExpirationTime,
getTimeUntilExpiration,
isB2BUser,
getB2BInfo,
shouldDisableRevenueCat,
getAppSettings,
} from './core/jwtUtils';
// Storage adapter
import {
setStorageAdapter as _setStorageAdapter,
createLocalStorageAdapter as _createLocalStorageAdapter,
setStorageAdapter as _setStorageAdapter,
createLocalStorageAdapter as _createLocalStorageAdapter,
} from './adapters/storage';
export {
setStorageAdapter,
getStorageAdapter,
isStorageInitialized,
createLocalStorageAdapter,
createMemoryStorageAdapter,
setStorageAdapter,
getStorageAdapter,
isStorageInitialized,
createLocalStorageAdapter,
createMemoryStorageAdapter,
} from './adapters/storage';
// Device adapter
import {
setDeviceAdapter as _setDeviceAdapter,
createWebDeviceAdapter as _createWebDeviceAdapter,
setDeviceAdapter as _setDeviceAdapter,
createWebDeviceAdapter as _createWebDeviceAdapter,
} from './adapters/device';
export {
setDeviceAdapter,
getDeviceAdapter,
isDeviceInitialized,
createWebDeviceAdapter,
setDeviceAdapter,
getDeviceAdapter,
isDeviceInitialized,
createWebDeviceAdapter,
} from './adapters/device';
// Network adapter
import {
setNetworkAdapter as _setNetworkAdapter,
createWebNetworkAdapter as _createWebNetworkAdapter,
setNetworkAdapter as _setNetworkAdapter,
createWebNetworkAdapter as _createWebNetworkAdapter,
} from './adapters/network';
export {
setNetworkAdapter,
getNetworkAdapter,
isDeviceConnected,
hasStableConnection,
createWebNetworkAdapter,
setNetworkAdapter,
getNetworkAdapter,
isDeviceConnected,
hasStableConnection,
createWebNetworkAdapter,
} from './adapters/network';
// Fetch interceptor
import { setupFetchInterceptor as _setupFetchInterceptor } from './interceptors/fetchInterceptor';
export {
setupFetchInterceptor,
setupTokenObservers,
getInterceptorStatus,
setupFetchInterceptor,
setupTokenObservers,
getInterceptorStatus,
} from './interceptors/fetchInterceptor';
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
@ -82,18 +82,21 @@ export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
* });
* ```
*/
export function initializeWebAuth(config: { baseUrl: string; storageKeys?: Partial<import('./types').StorageKeys> }) {
// Set up adapters
_setStorageAdapter(_createLocalStorageAdapter());
_setDeviceAdapter(_createWebDeviceAdapter());
_setNetworkAdapter(_createWebNetworkAdapter());
export function initializeWebAuth(config: {
baseUrl: string;
storageKeys?: Partial<import('./types').StorageKeys>;
}) {
// Set up adapters
_setStorageAdapter(_createLocalStorageAdapter());
_setDeviceAdapter(_createWebDeviceAdapter());
_setNetworkAdapter(_createWebNetworkAdapter());
// Create services
const authService = _createAuthService(config);
const tokenManager = _createTokenManager(authService);
// Create services
const authService = _createAuthService(config);
const tokenManager = _createTokenManager(authService);
// Set up interceptor
_setupFetchInterceptor(authService, tokenManager);
// Set up interceptor
_setupFetchInterceptor(authService, tokenManager);
return { authService, tokenManager };
return { authService, tokenManager };
}

View file

@ -6,215 +6,212 @@ import { TokenState } from '../types';
* Configuration for the fetch interceptor
*/
export interface FetchInterceptorConfig {
/**
* Patterns to skip (won't be intercepted)
*/
skipPatterns?: string[];
/**
* Backend URL to match (only intercept requests to this URL)
*/
backendUrl?: string;
/**
* Patterns to skip (won't be intercepted)
*/
skipPatterns?: string[];
/**
* Backend URL to match (only intercept requests to this URL)
*/
backendUrl?: string;
}
/**
* Default patterns to skip
*/
const DEFAULT_SKIP_PATTERNS = [
// Auth endpoints
'/auth/signin',
'/auth/signup',
'/auth/refresh',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/verify',
'/auth/logout',
// Public endpoints
'/health',
'/ping',
'/status',
'/version',
'/public/',
// Storage endpoints
'.supabase.co/storage/',
'/storage/v1/',
// External APIs
'googleapis.com',
'firebase.com',
'firebaseapp.com',
'replicate.com',
'openai.com',
'anthropic.com',
// Auth endpoints
'/auth/signin',
'/auth/signup',
'/auth/refresh',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/verify',
'/auth/logout',
// Public endpoints
'/health',
'/ping',
'/status',
'/version',
'/public/',
// Storage endpoints
'.supabase.co/storage/',
'/storage/v1/',
// External APIs
'googleapis.com',
'firebase.com',
'firebaseapp.com',
'replicate.com',
'openai.com',
'anthropic.com',
];
/**
* Setup a global fetch interceptor for automatic token handling
*/
export function setupFetchInterceptor(
authService: AuthService,
tokenManager: TokenManager,
config?: FetchInterceptorConfig
authService: AuthService,
tokenManager: TokenManager,
config?: FetchInterceptorConfig
): void {
if (typeof globalThis === 'undefined' || !globalThis.fetch) {
console.warn('FetchInterceptor: globalThis.fetch not available');
return;
}
if (typeof globalThis === 'undefined' || !globalThis.fetch) {
console.warn('FetchInterceptor: globalThis.fetch not available');
return;
}
const originalFetch = globalThis.fetch;
const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])];
const backendUrl = config?.backendUrl || authService.getBaseUrl();
const originalFetch = globalThis.fetch;
const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])];
const backendUrl = config?.backendUrl || authService.getBaseUrl();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = extractUrl(input);
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = extractUrl(input);
// Skip intercepting if URL doesn't match criteria
if (shouldSkipInterception(url, skipPatterns, backendUrl)) {
return originalFetch(input, init);
}
// Skip intercepting if URL doesn't match criteria
if (shouldSkipInterception(url, skipPatterns, backendUrl)) {
return originalFetch(input, init);
}
console.debug('Fetch interceptor: Intercepting URL:', url);
console.debug('Fetch interceptor: Intercepting URL:', url);
try {
// Make request with current token
const response = await makeRequestWithToken(originalFetch, authService, input, init);
try {
// Make request with current token
const response = await makeRequestWithToken(originalFetch, authService, input, init);
// Handle 401 responses
if (response.status === 401) {
const responseData = await response.clone().json().catch(() => ({}));
console.debug('Fetch interceptor: Received 401 response:', responseData);
// Handle 401 responses
if (response.status === 401) {
const responseData = await response
.clone()
.json()
.catch(() => ({}));
console.debug('Fetch interceptor: Received 401 response:', responseData);
if (isTokenExpiredResponse(responseData)) {
console.debug('Fetch interceptor: Token expired, delegating to TokenManager');
return tokenManager.handle401Response(input, init);
}
}
if (isTokenExpiredResponse(responseData)) {
console.debug('Fetch interceptor: Token expired, delegating to TokenManager');
return tokenManager.handle401Response(input, init);
}
}
return response;
} catch (error) {
console.debug('Error in global fetch interceptor:', error);
return originalFetch(input, init);
}
}) as typeof fetch;
return response;
} catch (error) {
console.debug('Error in global fetch interceptor:', error);
return originalFetch(input, init);
}
}) as typeof fetch;
}
/**
* Setup token state observers for integrations (e.g., Supabase)
*/
export function setupTokenObservers(
tokenManager: TokenManager,
onValid?: (token: string) => void | Promise<void>,
onExpired?: () => void | Promise<void>
tokenManager: TokenManager,
onValid?: (token: string) => void | Promise<void>,
onExpired?: () => void | Promise<void>
): () => void {
return tokenManager.subscribe(async (state, token) => {
try {
if (state === TokenState.VALID && token && onValid) {
await onValid(token);
} else if (state === TokenState.EXPIRED && onExpired) {
await onExpired();
}
} catch (error) {
console.debug('Error in token observer:', error);
}
});
return tokenManager.subscribe(async (state, token) => {
try {
if (state === TokenState.VALID && token && onValid) {
await onValid(token);
} else if (state === TokenState.EXPIRED && onExpired) {
await onExpired();
}
} catch (error) {
console.debug('Error in token observer:', error);
}
});
}
/**
* Extract URL from various input types
*/
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') {
return input;
} else if (input instanceof URL) {
return input.toString();
} else if (input instanceof Request) {
return input.url;
}
return '';
if (typeof input === 'string') {
return input;
} else if (input instanceof URL) {
return input.toString();
} else if (input instanceof Request) {
return input.url;
}
return '';
}
/**
* Check if request should skip interception
*/
function shouldSkipInterception(
url: string,
skipPatterns: string[],
backendUrl: string
): boolean {
if (!url) return true;
function shouldSkipInterception(url: string, skipPatterns: string[], backendUrl: string): boolean {
if (!url) return true;
const lowerUrl = url.toLowerCase();
const lowerUrl = url.toLowerCase();
// Check skip patterns
if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) {
return true;
}
// Check skip patterns
if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) {
return true;
}
// Check if URL matches backend
const backendDomain = backendUrl
.replace(/https?:\/\//, '')
.replace(/:\d+$/, '')
.toLowerCase();
// Check if URL matches backend
const backendDomain = backendUrl
.replace(/https?:\/\//, '')
.replace(/:\d+$/, '')
.toLowerCase();
if (!lowerUrl.includes(backendDomain)) {
return true;
}
if (!lowerUrl.includes(backendDomain)) {
return true;
}
return false;
return false;
}
/**
* Make a request with the current token
*/
async function makeRequestWithToken(
originalFetch: typeof fetch,
authService: AuthService,
input: RequestInfo | URL,
init?: RequestInit
originalFetch: typeof fetch,
authService: AuthService,
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token = await authService.getAppToken();
const token = await authService.getAppToken();
const requestInit: RequestInit = {
method: init?.method || 'GET',
...init,
};
const requestInit: RequestInit = {
method: init?.method || 'GET',
...init,
};
if (token) {
const headers = new Headers(requestInit.headers || {});
headers.set('Authorization', `Bearer ${token}`);
requestInit.headers = headers;
}
if (token) {
const headers = new Headers(requestInit.headers || {});
headers.set('Authorization', `Bearer ${token}`);
requestInit.headers = headers;
}
return originalFetch(input, requestInit);
return originalFetch(input, requestInit);
}
/**
* Check if response indicates token expiration
*/
function isTokenExpiredResponse(responseData: Record<string, unknown>): boolean {
const error = responseData.error as Record<string, unknown> | undefined;
const errorMessage = String(error?.message || responseData.message || responseData.error || '');
const errorCode = String(responseData.code || error?.code || '');
const error = responseData.error as Record<string, unknown> | undefined;
const errorMessage = String(error?.message || responseData.message || responseData.error || '');
const errorCode = String(responseData.code || error?.code || '');
return (
errorMessage === 'JWT expired' ||
errorCode === 'PGRST301' ||
errorMessage === 'Unauthorized'
);
return (
errorMessage === 'JWT expired' || errorCode === 'PGRST301' || errorMessage === 'Unauthorized'
);
}
/**
* Get interceptor status for debugging
*/
export function getInterceptorStatus(
authService: AuthService,
tokenManager: TokenManager
authService: AuthService,
tokenManager: TokenManager
): {
isSetup: boolean;
backendUrl: string;
tokenManager: { size: number; state: string; refreshAttempts: number };
isSetup: boolean;
backendUrl: string;
tokenManager: { size: number; state: string; refreshAttempts: number };
} {
return {
isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined,
backendUrl: authService.getBaseUrl(),
tokenManager: tokenManager.getQueueStatus(),
};
return {
isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined,
backendUrl: authService.getBaseUrl(),
tokenManager: tokenManager.getQueueStatus(),
};
}

View file

@ -2,83 +2,83 @@
* Storage keys for authentication data
*/
export interface StorageKeys {
APP_TOKEN: string;
REFRESH_TOKEN: string;
USER_EMAIL: string;
APP_TOKEN: string;
REFRESH_TOKEN: string;
USER_EMAIL: string;
}
/**
* Device information for multi-device support
*/
export interface DeviceInfo {
deviceId: string;
deviceName: string;
deviceType: string;
platform?: string;
deviceId: string;
deviceName: string;
deviceType: string;
platform?: string;
}
/**
* Decoded JWT token payload
*/
export interface DecodedToken {
sub: string;
email?: string;
role?: string;
exp: number;
iat: number;
aud?: string;
app_id?: string;
is_b2b?: boolean | string | number;
subscription_plan_id?: string;
user_metadata?: {
email?: string;
};
app_settings?: {
b2b?: {
disableRevenueCat?: boolean;
organizationId?: string;
plan?: string;
role?: string;
};
};
sub: string;
email?: string;
role?: string;
exp: number;
iat: number;
aud?: string;
app_id?: string;
is_b2b?: boolean | string | number;
subscription_plan_id?: string;
user_metadata?: {
email?: string;
};
app_settings?: {
b2b?: {
disableRevenueCat?: boolean;
organizationId?: string;
plan?: string;
role?: string;
};
};
}
/**
* User data extracted from token
*/
export interface UserData {
id: string;
email: string;
role: string;
id: string;
email: string;
role: string;
}
/**
* Authentication result from sign in/up
*/
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
success: boolean;
error?: string;
needsVerification?: boolean;
}
/**
* Token refresh result
*/
export interface TokenRefreshResult {
appToken: string;
refreshToken: string;
userData?: UserData | null;
appToken: string;
refreshToken: string;
userData?: UserData | null;
}
/**
* Token state for the token manager
*/
export enum TokenState {
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
EXPIRED_OFFLINE = 'expired_offline',
VALID = 'valid',
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
EXPIRED_OFFLINE = 'expired_offline',
VALID = 'valid',
}
/**
@ -90,89 +90,89 @@ export type TokenStateObserver = (state: TokenState, token?: string) => void;
* Queued request item during token refresh
*/
export interface QueuedRequest {
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
}
/**
* Internal token refresh result
*/
export interface InternalTokenRefreshResult {
success: boolean;
token?: string;
error?: string;
shouldPreserveAuth?: boolean;
shouldRetry?: boolean;
success: boolean;
token?: string;
error?: string;
shouldPreserveAuth?: boolean;
shouldRetry?: boolean;
}
/**
* Configuration for the auth service
*/
export interface AuthServiceConfig {
baseUrl: string;
storageKeys?: Partial<StorageKeys>;
endpoints?: Partial<AuthEndpoints>;
baseUrl: string;
storageKeys?: Partial<StorageKeys>;
endpoints?: Partial<AuthEndpoints>;
}
/**
* Auth API endpoints
*/
export interface AuthEndpoints {
signIn: string;
signUp: string;
signOut: string;
refresh: string;
validate: string;
forgotPassword: string;
googleSignIn: string;
appleSignIn: string;
credits: string;
signIn: string;
signUp: string;
signOut: string;
refresh: string;
validate: string;
forgotPassword: string;
googleSignIn: string;
appleSignIn: string;
credits: string;
}
/**
* Storage adapter interface
*/
export interface StorageAdapter {
getItem<T = string>(key: string): Promise<T | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
getItem<T = string>(key: string): Promise<T | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
/**
* Device manager adapter interface
*/
export interface DeviceManagerAdapter {
getDeviceInfo(): Promise<DeviceInfo>;
getStoredDeviceId(): Promise<string | null>;
getDeviceInfo(): Promise<DeviceInfo>;
getStoredDeviceId(): Promise<string | null>;
}
/**
* Network utilities adapter interface
*/
export interface NetworkAdapter {
isDeviceConnected(): Promise<boolean>;
hasStableConnection?(): Promise<boolean>;
isDeviceConnected(): Promise<boolean>;
hasStableConnection?(): Promise<boolean>;
}
/**
* Credit balance response
*/
export interface CreditBalance {
credits: number;
maxCreditLimit: number;
userId: string;
credits: number;
maxCreditLimit: number;
userId: string;
}
/**
* B2B information from JWT claims
*/
export interface B2BInfo {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
}

View file

@ -1,19 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -23,7 +23,7 @@ Display an app's logo:
```svelte
<script lang="ts">
import { AppLogo } from '@manacore/shared-branding';
import { AppLogo } from '@manacore/shared-branding';
</script>
<AppLogo app="memoro" size={32} />
@ -38,7 +38,7 @@ Display logo with app name (perfect for headers):
```svelte
<script lang="ts">
import { AppLogoWithName } from '@manacore/shared-branding';
import { AppLogoWithName } from '@manacore/shared-branding';
</script>
<AppLogoWithName app="memoro" size={28} />
@ -51,7 +51,7 @@ Universal Mana drop icon:
```svelte
<script lang="ts">
import { ManaIcon } from '@manacore/shared-branding';
import { ManaIcon } from '@manacore/shared-branding';
</script>
<ManaIcon size={24} color="#4287f5" />
@ -65,50 +65,50 @@ Access branding config programmatically:
import { getAppBranding, APP_BRANDING } from '@manacore/shared-branding';
const memoro = getAppBranding('memoro');
console.log(memoro.name); // "Memoro"
console.log(memoro.tagline); // "AI Voice Memos"
console.log(memoro.name); // "Memoro"
console.log(memoro.tagline); // "AI Voice Memos"
console.log(memoro.primaryColor); // "#f8d62b"
```
## App Branding
| App | Name | Primary Color | Tagline |
|-----|------|---------------|---------|
| `memoro` | Memoro | #f8d62b (Gold) | AI Voice Memos |
| `manacore` | ManaCore | #6366f1 (Indigo) | Central Hub |
| `manadeck` | ManaDeck | #8b5cf6 (Purple) | AI Flashcards |
| `maerchenzauber` | Märchenzauber | #ec4899 (Pink) | AI Story Creator |
| App | Name | Primary Color | Tagline |
| ---------------- | ------------- | ---------------- | ---------------- |
| `memoro` | Memoro | #f8d62b (Gold) | AI Voice Memos |
| `manacore` | ManaCore | #6366f1 (Indigo) | Central Hub |
| `manadeck` | ManaDeck | #8b5cf6 (Purple) | AI Flashcards |
| `maerchenzauber` | Märchenzauber | #ec4899 (Pink) | AI Story Creator |
## Props
### AppLogo
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `app` | `AppId` | required | App identifier |
| `size` | `number` | `32` | Size in pixels |
| `color` | `string` | App primary color | Override color |
| `class` | `string` | `''` | Additional CSS classes |
| Prop | Type | Default | Description |
| ------- | -------- | ----------------- | ---------------------- |
| `app` | `AppId` | required | App identifier |
| `size` | `number` | `32` | Size in pixels |
| `color` | `string` | App primary color | Override color |
| `class` | `string` | `''` | Additional CSS classes |
### AppLogoWithName
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `app` | `AppId` | required | App identifier |
| `size` | `number` | `28` | Logo size in pixels |
| `color` | `string` | App primary color | Override color |
| `showName` | `boolean` | `true` | Show app name |
| `nameFontSize` | `string` | `'1.25rem'` | Name font size |
| `gap` | `string` | `'0.5rem'` | Gap between logo and name |
| `class` | `string` | `''` | Additional CSS classes |
| Prop | Type | Default | Description |
| -------------- | --------- | ----------------- | ------------------------- |
| `app` | `AppId` | required | App identifier |
| `size` | `number` | `28` | Logo size in pixels |
| `color` | `string` | App primary color | Override color |
| `showName` | `boolean` | `true` | Show app name |
| `nameFontSize` | `string` | `'1.25rem'` | Name font size |
| `gap` | `string` | `'0.5rem'` | Gap between logo and name |
| `class` | `string` | `''` | Additional CSS classes |
### ManaIcon
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `size` | `number` | `24` | Size in pixels |
| `color` | `string` | `'#4287f5'` | Icon color |
| `class` | `string` | `''` | Additional CSS classes |
| Prop | Type | Default | Description |
| ------- | -------- | ----------- | ---------------------- |
| `size` | `number` | `24` | Size in pixels |
| `color` | `string` | `'#4287f5'` | Icon color |
| `class` | `string` | `''` | Additional CSS classes |
## Types
@ -116,14 +116,14 @@ console.log(memoro.primaryColor); // "#f8d62b"
type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber';
interface AppBranding {
id: AppId;
name: string;
tagline: string;
primaryColor: string;
secondaryColor?: string;
logoPath: string;
logoViewBox?: string;
logoStroke?: boolean;
logoStrokeWidth?: number;
id: AppId;
name: string;
tagline: string;
primaryColor: string;
secondaryColor?: string;
logoPath: string;
logoViewBox?: string;
logoStroke?: boolean;
logoStrokeWidth?: number;
}
```

View file

@ -1,27 +1,27 @@
{
"name": "@manacore/shared-branding",
"version": "1.0.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
"name": "@manacore/shared-branding",
"version": "1.0.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -13,12 +13,7 @@
class?: string;
}
let {
app,
size = 32,
color,
class: className = ''
}: Props = $props();
let { app, size = 32, color, class: className = '' }: Props = $props();
const branding = $derived(APP_BRANDING[app]);
const fillColor = $derived(color ?? branding.primaryColor);
@ -42,11 +37,6 @@
stroke-linejoin="round"
/>
{:else}
<path
d={branding.logoPath}
fill={fillColor}
fill-rule="evenodd"
clip-rule="evenodd"
/>
<path d={branding.logoPath} fill={fillColor} fill-rule="evenodd" clip-rule="evenodd" />
{/if}
</svg>

View file

@ -27,7 +27,7 @@
showName = true,
nameFontSize = '1.25rem',
gap = '0.5rem',
class: className = ''
class: className = '',
}: Props = $props();
const branding = $derived(APP_BRANDING[app]);

View file

@ -12,11 +12,7 @@
class?: string;
}
let {
size = 24,
color = '#4287f5',
class: className = ''
}: Props = $props();
let { size = 24, color = '#4287f5', class: className = '' }: Props = $props();
</script>
<svg
@ -28,5 +24,7 @@
class={className}
aria-label="Mana"
>
<path d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z" />
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>

View file

@ -11,7 +11,8 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
primaryColor: '#f8d62b',
secondaryColor: '#f7d44c',
// Memoro smile/face logo
logoPath: 'M280 140C280 217.32 217.32 280 140 280C62.6801 280 0 217.32 0 140C0 62.6801 62.6801 0 140 0C217.32 0 280 62.6801 280 140ZM247.988 140C247.988 199.64 199.64 241.988 140 241.988C80.3598 241.988 32.0118 199.64 32.0118 140C32.0118 111.918 36.7308 95.3397 54.3005 76.1331C58.5193 71.5212 70.5 63 79.3937 74.511L119.781 131.788C134.5 149 149 147 160.218 131.788L200.605 74.5101C208 64 221.48 71.5203 225.699 76.1321C243.269 95.3388 247.988 111.918 247.988 140Z',
logoPath:
'M280 140C280 217.32 217.32 280 140 280C62.6801 280 0 217.32 0 140C0 62.6801 62.6801 0 140 0C217.32 0 280 62.6801 280 140ZM247.988 140C247.988 199.64 199.64 241.988 140 241.988C80.3598 241.988 32.0118 199.64 32.0118 140C32.0118 111.918 36.7308 95.3397 54.3005 76.1331C58.5193 71.5212 70.5 63 79.3937 74.511L119.781 131.788C134.5 149 149 147 160.218 131.788L200.605 74.5101C208 64 221.48 71.5203 225.699 76.1321C243.269 95.3388 247.988 111.918 247.988 140Z',
logoViewBox: '0 0 280 280',
logoStroke: false,
},
@ -46,7 +47,8 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
primaryColor: '#ec4899',
secondaryColor: '#f472b6',
// Book/Story icon
logoPath: 'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25',
logoPath:
'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
@ -58,7 +60,8 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Link/Chain icon
logoPath: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
logoPath:
'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 2,
@ -70,7 +73,8 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
primaryColor: '#0ea5e9',
secondaryColor: '#38bdf8',
// Chat bubble icon
logoPath: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
logoPath:
'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,

View file

@ -20,16 +20,11 @@ export {
ManaDeckLogo,
StorytellerLogo,
UloadLogo,
ChatLogo
ChatLogo,
} from './logos';
// Configuration
export { APP_BRANDING, getAppBranding, getAllAppBrandings } from './config';
// Types
export type {
AppId,
AppBranding,
LogoProps,
AppLogoWithNameProps,
} from './types';
export type { AppId, AppBranding, LogoProps, AppLogoWithNameProps } from './types';

View file

@ -1,19 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,24 +1,24 @@
{
"name": "@manacore/shared-config",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./api": "./src/api.ts",
"./features": "./src/features.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.7.3"
}
"name": "@manacore/shared-config",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./api": "./src/api.ts",
"./features": "./src/features.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.7.3"
}
}

View file

@ -6,202 +6,202 @@
* API configuration
*/
export interface ApiConfig {
/** Base URL for the API */
baseUrl: string;
/** API version prefix (e.g., 'v1') */
version?: string;
/** Default timeout in milliseconds */
timeout?: number;
/** Default headers */
headers?: Record<string, string>;
/** Base URL for the API */
baseUrl: string;
/** API version prefix (e.g., 'v1') */
version?: string;
/** Default timeout in milliseconds */
timeout?: number;
/** Default headers */
headers?: Record<string, string>;
}
/**
* Create API endpoint URL builder
*/
export function createApiBuilder(config: ApiConfig) {
const { baseUrl, version } = config;
const { baseUrl, version } = config;
// Remove trailing slash from base URL
const base = baseUrl.replace(/\/$/, '');
// Remove trailing slash from base URL
const base = baseUrl.replace(/\/$/, '');
// Build base path with optional version
const basePath = version ? `${base}/${version}` : base;
// Build base path with optional version
const basePath = version ? `${base}/${version}` : base;
return {
/**
* Build endpoint URL from path segments
*/
endpoint(...segments: (string | number)[]): string {
const path = segments
.map(String)
.map(s => s.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
.filter(Boolean)
.join('/');
return {
/**
* Build endpoint URL from path segments
*/
endpoint(...segments: (string | number)[]): string {
const path = segments
.map(String)
.map((s) => s.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
.filter(Boolean)
.join('/');
return `${basePath}/${path}`;
},
return `${basePath}/${path}`;
},
/**
* Build endpoint URL with query parameters
*/
endpointWithQuery(
path: string | string[],
params?: Record<string, string | number | boolean | undefined>
): string {
const segments = Array.isArray(path) ? path : [path];
const url = this.endpoint(...segments);
/**
* Build endpoint URL with query parameters
*/
endpointWithQuery(
path: string | string[],
params?: Record<string, string | number | boolean | undefined>
): string {
const segments = Array.isArray(path) ? path : [path];
const url = this.endpoint(...segments);
if (!params) {
return url;
}
if (!params) {
return url;
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
},
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
},
/**
* Get the base URL
*/
getBaseUrl(): string {
return basePath;
},
/**
* Get the base URL
*/
getBaseUrl(): string {
return basePath;
},
/**
* Get the config
*/
getConfig(): ApiConfig {
return config;
},
};
/**
* Get the config
*/
getConfig(): ApiConfig {
return config;
},
};
}
/**
* Build URL with query parameters
*/
export function buildUrl(
baseUrl: string,
path: string,
params?: Record<string, string | number | boolean | undefined>
baseUrl: string,
path: string,
params?: Record<string, string | number | boolean | undefined>
): string {
// Ensure single slash between base and path
const base = baseUrl.replace(/\/$/, '');
const cleanPath = path.replace(/^\//, '');
const url = `${base}/${cleanPath}`;
// Ensure single slash between base and path
const base = baseUrl.replace(/\/$/, '');
const cleanPath = path.replace(/^\//, '');
const url = `${base}/${cleanPath}`;
if (!params) {
return url;
}
if (!params) {
return url;
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
}
/**
* Parse URL and extract components
*/
export function parseUrl(url: string): {
protocol: string;
host: string;
port: string;
pathname: string;
search: string;
params: Record<string, string>;
protocol: string;
host: string;
port: string;
pathname: string;
search: string;
params: Record<string, string>;
} {
const urlObj = new URL(url);
const urlObj = new URL(url);
const params: Record<string, string> = {};
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
const params: Record<string, string> = {};
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
return {
protocol: urlObj.protocol.replace(':', ''),
host: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
search: urlObj.search,
params,
};
return {
protocol: urlObj.protocol.replace(':', ''),
host: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
search: urlObj.search,
params,
};
}
/**
* Join URL path segments
*/
export function joinPath(...segments: string[]): string {
return segments
.map(s => s.replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.join('/');
return segments
.map((s) => s.replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.join('/');
}
/**
* Common HTTP methods
*/
export const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
PATCH: 'PATCH',
DELETE: 'DELETE',
HEAD: 'HEAD',
OPTIONS: 'OPTIONS',
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
PATCH: 'PATCH',
DELETE: 'DELETE',
HEAD: 'HEAD',
OPTIONS: 'OPTIONS',
} as const;
export type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS];
export type HttpMethod = (typeof HTTP_METHODS)[keyof typeof HTTP_METHODS];
/**
* Common HTTP status codes
*/
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
} as const;
export type HttpStatus = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
export type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS];
/**
* Check if status code is successful (2xx)
*/
export function isSuccessStatus(status: number): boolean {
return status >= 200 && status < 300;
return status >= 200 && status < 300;
}
/**
* Check if status code is client error (4xx)
*/
export function isClientError(status: number): boolean {
return status >= 400 && status < 500;
return status >= 400 && status < 500;
}
/**
* Check if status code is server error (5xx)
*/
export function isServerError(status: number): boolean {
return status >= 500 && status < 600;
return status >= 500 && status < 600;
}

View file

@ -8,166 +8,163 @@ import { z } from 'zod';
* Common environment variable schemas
*/
export const envSchemas = {
/** URL schema */
url: z.string().url(),
/** URL schema */
url: z.string().url(),
/** Non-empty string schema */
nonEmpty: z.string().min(1),
/** Non-empty string schema */
nonEmpty: z.string().min(1),
/** Optional string schema */
optional: z.string().optional(),
/** Optional string schema */
optional: z.string().optional(),
/** Port number schema */
port: z.coerce.number().int().min(1).max(65535),
/** Port number schema */
port: z.coerce.number().int().min(1).max(65535),
/** Boolean schema (accepts various formats) */
boolean: z.preprocess(
(val) => {
if (typeof val === 'boolean') return val;
if (typeof val === 'string') {
return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
}
return false;
},
z.boolean()
),
/** Boolean schema (accepts various formats) */
boolean: z.preprocess((val) => {
if (typeof val === 'boolean') return val;
if (typeof val === 'string') {
return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
}
return false;
}, z.boolean()),
/** Number schema */
number: z.coerce.number(),
/** Number schema */
number: z.coerce.number(),
/** Positive integer schema */
positiveInt: z.coerce.number().int().positive(),
/** Positive integer schema */
positiveInt: z.coerce.number().int().positive(),
/** Email schema */
email: z.string().email(),
/** Email schema */
email: z.string().email(),
/** Node environment schema */
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
/** Node environment schema */
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
};
/**
* Common Supabase environment schema
*/
export const supabaseEnvSchema = z.object({
SUPABASE_URL: envSchemas.url,
SUPABASE_ANON_KEY: envSchemas.nonEmpty,
SUPABASE_SERVICE_ROLE_KEY: envSchemas.nonEmpty.optional(),
SUPABASE_URL: envSchemas.url,
SUPABASE_ANON_KEY: envSchemas.nonEmpty,
SUPABASE_SERVICE_ROLE_KEY: envSchemas.nonEmpty.optional(),
});
/**
* Common app environment schema
*/
export const appEnvSchema = z.object({
NODE_ENV: envSchemas.nodeEnv,
PORT: envSchemas.port.default(3000),
NODE_ENV: envSchemas.nodeEnv,
PORT: envSchemas.port.default(3000),
});
/**
* Create an environment config from schema
*/
export function createEnvConfig<T extends z.ZodTypeAny>(
schema: T,
env: NodeJS.ProcessEnv = process.env
schema: T,
env: NodeJS.ProcessEnv = process.env
): z.infer<T> {
const result = schema.safeParse(env);
const result = schema.safeParse(env);
if (!result.success) {
const errors = result.error.errors
.map((err) => ` ${err.path.join('.')}: ${err.message}`)
.join('\n');
if (!result.success) {
const errors = result.error.errors
.map((err) => ` ${err.path.join('.')}: ${err.message}`)
.join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
throw new Error(`Environment validation failed:\n${errors}`);
}
return result.data;
return result.data;
}
/**
* Validate environment variables with custom schema
*/
export function validateEnv<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
env: NodeJS.ProcessEnv = process.env
schema: z.ZodObject<T>,
env: NodeJS.ProcessEnv = process.env
): z.infer<z.ZodObject<T>> {
return createEnvConfig(schema, env);
return createEnvConfig(schema, env);
}
/**
* Get required environment variable with type safety
*/
export function getRequiredEnv(key: string, env: NodeJS.ProcessEnv = process.env): string {
const value = env[key];
const value = env[key];
if (value === undefined || value === '') {
throw new Error(`Required environment variable "${key}" is not set`);
}
if (value === undefined || value === '') {
throw new Error(`Required environment variable "${key}" is not set`);
}
return value;
return value;
}
/**
* Get optional environment variable with default
*/
export function getEnv(
key: string,
defaultValue: string,
env: NodeJS.ProcessEnv = process.env
key: string,
defaultValue: string,
env: NodeJS.ProcessEnv = process.env
): string {
return env[key] ?? defaultValue;
return env[key] ?? defaultValue;
}
/**
* Get boolean environment variable
*/
export function getBoolEnv(
key: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
key: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
): boolean {
const value = env[key];
const value = env[key];
if (value === undefined) {
return defaultValue;
}
if (value === undefined) {
return defaultValue;
}
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
}
/**
* Get number environment variable
*/
export function getNumEnv(
key: string,
defaultValue: number,
env: NodeJS.ProcessEnv = process.env
key: string,
defaultValue: number,
env: NodeJS.ProcessEnv = process.env
): number {
const value = env[key];
const value = env[key];
if (value === undefined) {
return defaultValue;
}
if (value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : parsed;
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Check if running in development
*/
export function isDevelopment(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'development';
return env.NODE_ENV === 'development';
}
/**
* Check if running in production
*/
export function isProduction(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'production';
return env.NODE_ENV === 'production';
}
/**
* Check if running in test
*/
export function isTest(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'test';
return env.NODE_ENV === 'test';
}

View file

@ -6,168 +6,168 @@
* Feature flag configuration
*/
export interface FeatureFlag {
/** Feature key */
key: string;
/** Default enabled state */
defaultEnabled: boolean;
/** Description */
description?: string;
/** Environment variable to override */
envVar?: string;
/** Feature key */
key: string;
/** Default enabled state */
defaultEnabled: boolean;
/** Description */
description?: string;
/** Environment variable to override */
envVar?: string;
}
/**
* Create a feature flag manager
*/
export function createFeatureFlags<T extends Record<string, FeatureFlag>>(
flags: T,
env: NodeJS.ProcessEnv = process.env
flags: T,
env: NodeJS.ProcessEnv = process.env
) {
type FlagKey = keyof T;
type FlagKey = keyof T;
/**
* Check if a feature is enabled
*/
function isEnabled(key: FlagKey): boolean {
const flag = flags[key];
/**
* Check if a feature is enabled
*/
function isEnabled(key: FlagKey): boolean {
const flag = flags[key];
if (!flag) {
return false;
}
if (!flag) {
return false;
}
// Check environment variable override
if (flag.envVar) {
const envValue = env[flag.envVar];
if (envValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(envValue.toLowerCase());
}
}
// Check environment variable override
if (flag.envVar) {
const envValue = env[flag.envVar];
if (envValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(envValue.toLowerCase());
}
}
// Check generic feature flag env var
const genericEnvVar = `FEATURE_${String(key).toUpperCase()}`;
const genericValue = env[genericEnvVar];
if (genericValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(genericValue.toLowerCase());
}
// Check generic feature flag env var
const genericEnvVar = `FEATURE_${String(key).toUpperCase()}`;
const genericValue = env[genericEnvVar];
if (genericValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(genericValue.toLowerCase());
}
return flag.defaultEnabled;
}
return flag.defaultEnabled;
}
/**
* Get all enabled features
*/
function getEnabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter(isEnabled);
}
/**
* Get all enabled features
*/
function getEnabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter(isEnabled);
}
/**
* Get all disabled features
*/
function getDisabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter(key => !isEnabled(key));
}
/**
* Get all disabled features
*/
function getDisabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter((key) => !isEnabled(key));
}
/**
* Get feature configuration
*/
function getFlag(key: FlagKey): FeatureFlag | undefined {
return flags[key];
}
/**
* Get feature configuration
*/
function getFlag(key: FlagKey): FeatureFlag | undefined {
return flags[key];
}
/**
* Get all flags with their current state
*/
function getAllFlags(): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const key of Object.keys(flags) as FlagKey[]) {
result[String(key)] = isEnabled(key);
}
return result;
}
/**
* Get all flags with their current state
*/
function getAllFlags(): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const key of Object.keys(flags) as FlagKey[]) {
result[String(key)] = isEnabled(key);
}
return result;
}
return {
isEnabled,
getEnabledFeatures,
getDisabledFeatures,
getFlag,
getAllFlags,
};
return {
isEnabled,
getEnabledFeatures,
getDisabledFeatures,
getFlag,
getAllFlags,
};
}
/**
* Simple feature check using environment variable
*/
export function isFeatureEnabled(
featureName: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
featureName: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
): boolean {
const envVar = `FEATURE_${featureName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const value = env[envVar];
const envVar = `FEATURE_${featureName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const value = env[envVar];
if (value === undefined) {
return defaultValue;
}
if (value === undefined) {
return defaultValue;
}
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
}
/**
* App metadata configuration
*/
export interface AppMetadata {
/** App name */
name: string;
/** App version */
version: string;
/** App description */
description?: string;
/** Build number */
buildNumber?: string;
/** Git commit hash */
commitHash?: string;
/** Build timestamp */
buildTime?: string;
/** Environment */
environment?: string;
/** App name */
name: string;
/** App version */
version: string;
/** App description */
description?: string;
/** Build number */
buildNumber?: string;
/** Git commit hash */
commitHash?: string;
/** Build timestamp */
buildTime?: string;
/** Environment */
environment?: string;
}
/**
* Create app metadata from environment
*/
export function createAppMetadata(
config: {
name: string;
version: string;
description?: string;
},
env: NodeJS.ProcessEnv = process.env
config: {
name: string;
version: string;
description?: string;
},
env: NodeJS.ProcessEnv = process.env
): AppMetadata {
return {
name: config.name,
version: config.version,
description: config.description,
buildNumber: env.BUILD_NUMBER || env.VITE_BUILD_NUMBER,
commitHash: env.COMMIT_HASH || env.VITE_COMMIT_HASH || env.GIT_COMMIT,
buildTime: env.BUILD_TIME || env.VITE_BUILD_TIME,
environment: env.NODE_ENV || 'development',
};
return {
name: config.name,
version: config.version,
description: config.description,
buildNumber: env.BUILD_NUMBER || env.VITE_BUILD_NUMBER,
commitHash: env.COMMIT_HASH || env.VITE_COMMIT_HASH || env.GIT_COMMIT,
buildTime: env.BUILD_TIME || env.VITE_BUILD_TIME,
environment: env.NODE_ENV || 'development',
};
}
/**
* Format version string with build info
*/
export function formatVersion(metadata: AppMetadata): string {
let version = metadata.version;
let version = metadata.version;
if (metadata.buildNumber) {
version += ` (${metadata.buildNumber})`;
}
if (metadata.buildNumber) {
version += ` (${metadata.buildNumber})`;
}
if (metadata.commitHash) {
const shortHash = metadata.commitHash.substring(0, 7);
version += ` [${shortHash}]`;
}
if (metadata.commitHash) {
const shortHash = metadata.commitHash.substring(0, 7);
version += ` [${shortHash}]`;
}
return version;
return version;
}

View file

@ -7,42 +7,42 @@
// Environment utilities
export {
envSchemas,
supabaseEnvSchema,
appEnvSchema,
createEnvConfig,
validateEnv,
getRequiredEnv,
getEnv,
getBoolEnv,
getNumEnv,
isDevelopment,
isProduction,
isTest,
envSchemas,
supabaseEnvSchema,
appEnvSchema,
createEnvConfig,
validateEnv,
getRequiredEnv,
getEnv,
getBoolEnv,
getNumEnv,
isDevelopment,
isProduction,
isTest,
} from './env';
// API utilities
export {
type ApiConfig,
createApiBuilder,
buildUrl,
parseUrl,
joinPath,
HTTP_METHODS,
HTTP_STATUS,
type HttpMethod,
type HttpStatus,
isSuccessStatus,
isClientError,
isServerError,
type ApiConfig,
createApiBuilder,
buildUrl,
parseUrl,
joinPath,
HTTP_METHODS,
HTTP_STATUS,
type HttpMethod,
type HttpStatus,
isSuccessStatus,
isClientError,
isServerError,
} from './api';
// Feature flag utilities
export {
type FeatureFlag,
createFeatureFlags,
isFeatureEnabled,
type AppMetadata,
createAppMetadata,
formatVersion,
type FeatureFlag,
createFeatureFlags,
isFeatureEnabled,
type AppMetadata,
createAppMetadata,
formatVersion,
} from './features';

View file

@ -1,19 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,25 +1,25 @@
{
"name": "@manacore/shared-credit-service",
"version": "0.0.1",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-subscription-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.0.0"
}
"name": "@manacore/shared-credit-service",
"version": "0.0.1",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"src"
],
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-subscription-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}

View file

@ -28,7 +28,7 @@ import type {
CreditCheckResponse,
PricingResponse,
CreditUpdateCallback,
StandardOperationType
StandardOperationType,
} from './types';
import { DEFAULT_OPERATION_PRICING } from './types';
@ -42,7 +42,7 @@ export function createCreditService(config: CreditServiceConfig) {
pricingEndpoint = '/credits/pricing',
cacheDuration = 30 * 60 * 1000, // 30 minutes default
fallbackPricing = {},
getAuthToken
getAuthToken,
} = config;
// Normalize API URL (remove trailing slash)
@ -56,7 +56,7 @@ export function createCreditService(config: CreditServiceConfig) {
// Merge fallback pricing with defaults
const mergedFallbackPricing = {
...DEFAULT_OPERATION_PRICING,
...fallbackPricing
...fallbackPricing,
};
/**
@ -121,8 +121,8 @@ export function createCreditService(config: CreditServiceConfig) {
const response = await fetch(`${baseUrl}${pricingEndpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
if (!response.ok) {
@ -145,7 +145,7 @@ export function createCreditService(config: CreditServiceConfig) {
// Return fallback pricing
return {
operationCosts: mergedFallbackPricing,
lastUpdated: new Date().toISOString()
lastUpdated: new Date().toISOString(),
};
}
}
@ -166,8 +166,8 @@ export function createCreditService(config: CreditServiceConfig) {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
});
if (!response.ok) {
@ -183,7 +183,7 @@ export function createCreditService(config: CreditServiceConfig) {
credits: data.data.credits,
maxCreditLimit: data.data.maxCreditLimit ?? data.data.credits,
userId: data.data.userId ?? '',
lastUpdated: new Date().toISOString()
lastUpdated: new Date().toISOString(),
};
}
@ -193,7 +193,7 @@ export function createCreditService(config: CreditServiceConfig) {
credits: data.credits,
maxCreditLimit: data.maxCreditLimit ?? data.credits,
userId: data.userId ?? '',
lastUpdated: new Date().toISOString()
lastUpdated: new Date().toISOString(),
};
}
@ -230,7 +230,10 @@ export function createCreditService(config: CreditServiceConfig) {
/**
* Calculate cost for multiple units of an operation
*/
async function calculateCost(operation: StandardOperationType, quantity: number = 1): Promise<number> {
async function calculateCost(
operation: StandardOperationType,
quantity: number = 1
): Promise<number> {
const unitCost = await getOperationCost(operation);
return unitCost * quantity;
}
@ -257,7 +260,7 @@ export function createCreditService(config: CreditServiceConfig) {
hasEnoughCredits: false,
currentCredits: 0,
requiredCredits,
deficit: requiredCredits
deficit: requiredCredits,
};
}
@ -268,14 +271,16 @@ export function createCreditService(config: CreditServiceConfig) {
currentCredits: balance.credits,
requiredCredits,
deficit: hasEnough ? undefined : requiredCredits - balance.credits,
context: operation ? { operation } : undefined
context: operation ? { operation } : undefined,
};
}
/**
* Check if user has enough credits for a specific operation
*/
async function checkOperationBalance(operation: StandardOperationType): Promise<CreditCheckResponse> {
async function checkOperationBalance(
operation: StandardOperationType
): Promise<CreditCheckResponse> {
const cost = await getOperationCost(operation);
return checkBalance(cost, operation);
}
@ -317,7 +322,7 @@ export function createCreditService(config: CreditServiceConfig) {
// Utilities
formatCredits,
clearCache
clearCache,
};
}

View file

@ -68,7 +68,7 @@ export type {
CreditConsumptionResponse,
PricingResponse,
CreditUpdateCallback,
StandardOperationType
StandardOperationType,
} from './types';
// Constants

View file

@ -150,5 +150,5 @@ export const DEFAULT_OPERATION_PRICING: Record<string, number> = {
// Generic
AI_PROCESSING: 10,
EXPORT: 1,
IMPORT: 1
IMPORT: 1,
};

View file

@ -1,16 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noEmit": true
},
"include": ["src/**/*"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noEmit": true
},
"include": ["src/**/*"]
}

View file

@ -1,42 +1,44 @@
{
"name": "@manacore/shared-errors",
"version": "0.1.0",
"private": true,
"description": "Go-like error handling system for Manacore backends",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"default": "./dist/nestjs/index.js"
}
},
"typesVersions": {
"*": {
"nestjs": ["./dist/nestjs/index.d.ts"]
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"@nestjs/common": ">=10.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": true
}
},
"devDependencies": {
"@nestjs/common": "^11.0.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
"name": "@manacore/shared-errors",
"version": "0.1.0",
"private": true,
"description": "Go-like error handling system for Manacore backends",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"default": "./dist/nestjs/index.js"
}
},
"typesVersions": {
"*": {
"nestjs": [
"./dist/nestjs/index.d.ts"
]
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"peerDependencies": {
"@nestjs/common": ">=10.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": {
"optional": true
}
},
"devDependencies": {
"@nestjs/common": "^11.0.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}

View file

@ -1,26 +1,22 @@
import {
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
ERROR_CODE_RETRYABLE,
} from '../types/error-codes';
import { ErrorCode, ERROR_CODE_TO_HTTP_STATUS, ERROR_CODE_RETRYABLE } from '../types/error-codes';
/**
* Additional context that can be attached to errors.
*/
export interface ErrorContext {
[key: string]: unknown;
[key: string]: unknown;
}
/**
* Options for creating an AppError.
*/
export interface AppErrorOptions {
code: ErrorCode;
message: string;
cause?: Error | AppError;
context?: ErrorContext;
httpStatus?: number;
retryable?: boolean;
code: ErrorCode;
message: string;
cause?: Error | AppError;
context?: ErrorContext;
httpStatus?: number;
retryable?: boolean;
}
/**
@ -50,130 +46,126 @@ export interface AppErrorOptions {
* ```
*/
export class AppError extends Error {
/** Standardized error code */
readonly code: ErrorCode;
/** Standardized error code */
readonly code: ErrorCode;
/** HTTP status code for API responses */
readonly httpStatus: number;
/** HTTP status code for API responses */
readonly httpStatus: number;
/** Whether the operation can be retried */
readonly retryable: boolean;
/** Whether the operation can be retried */
readonly retryable: boolean;
/** Original error that caused this error (for wrapping) */
readonly cause?: Error | AppError;
/** Original error that caused this error (for wrapping) */
readonly cause?: Error | AppError;
/** Additional context information */
readonly context: ErrorContext;
/** Additional context information */
readonly context: ErrorContext;
/** Timestamp when error was created */
readonly timestamp: string;
/** Timestamp when error was created */
readonly timestamp: string;
constructor(options: AppErrorOptions) {
super(options.message);
this.name = 'AppError';
this.code = options.code;
this.cause = options.cause;
this.context = options.context ?? {};
this.timestamp = new Date().toISOString();
constructor(options: AppErrorOptions) {
super(options.message);
this.name = 'AppError';
this.code = options.code;
this.cause = options.cause;
this.context = options.context ?? {};
this.timestamp = new Date().toISOString();
// Use provided values or defaults from mappings
this.httpStatus =
options.httpStatus ?? ERROR_CODE_TO_HTTP_STATUS[options.code];
this.retryable = options.retryable ?? ERROR_CODE_RETRYABLE[options.code];
// Use provided values or defaults from mappings
this.httpStatus = options.httpStatus ?? ERROR_CODE_TO_HTTP_STATUS[options.code];
this.retryable = options.retryable ?? ERROR_CODE_RETRYABLE[options.code];
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
}
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
}
/**
* Create a wrapped error with additional context.
* Similar to Go's `fmt.Errorf("context: %w", err)`.
*
* @param contextMessage - Description of the operation that failed
* @param additionalContext - Extra context data to include
* @returns A new AppError with the original as its cause
*
* @example
* ```typescript
* const wrapped = originalError.wrap('fetching user data');
* // Message: "fetching user data: original message"
* ```
*/
wrap(contextMessage: string, additionalContext?: ErrorContext): AppError {
return new AppError({
code: this.code,
message: `${contextMessage}: ${this.message}`,
cause: this,
context: { ...this.context, ...additionalContext },
httpStatus: this.httpStatus,
retryable: this.retryable,
});
}
/**
* Create a wrapped error with additional context.
* Similar to Go's `fmt.Errorf("context: %w", err)`.
*
* @param contextMessage - Description of the operation that failed
* @param additionalContext - Extra context data to include
* @returns A new AppError with the original as its cause
*
* @example
* ```typescript
* const wrapped = originalError.wrap('fetching user data');
* // Message: "fetching user data: original message"
* ```
*/
wrap(contextMessage: string, additionalContext?: ErrorContext): AppError {
return new AppError({
code: this.code,
message: `${contextMessage}: ${this.message}`,
cause: this,
context: { ...this.context, ...additionalContext },
httpStatus: this.httpStatus,
retryable: this.retryable,
});
}
/**
* Get the root cause of the error chain.
* Traverses the cause chain to find the original error.
*/
rootCause(): Error {
let current: Error = this;
while (current instanceof AppError && current.cause) {
current = current.cause;
}
return current;
}
/**
* Get the root cause of the error chain.
* Traverses the cause chain to find the original error.
*/
rootCause(): Error {
let current: Error = this;
while (current instanceof AppError && current.cause) {
current = current.cause;
}
return current;
}
/**
* Check if this error or any in the chain has the given code.
* Similar to Go's `errors.Is()`.
*
* @param code - The error code to check for
* @returns true if this error or any cause has the given code
*
* @example
* ```typescript
* if (error.hasCode(ErrorCode.INSUFFICIENT_CREDITS)) {
* // Show upgrade prompt
* }
* ```
*/
hasCode(code: ErrorCode): boolean {
let current: Error | undefined = this;
while (current) {
if (current instanceof AppError && current.code === code) {
return true;
}
current = current instanceof AppError ? current.cause : undefined;
}
return false;
}
/**
* Check if this error or any in the chain has the given code.
* Similar to Go's `errors.Is()`.
*
* @param code - The error code to check for
* @returns true if this error or any cause has the given code
*
* @example
* ```typescript
* if (error.hasCode(ErrorCode.INSUFFICIENT_CREDITS)) {
* // Show upgrade prompt
* }
* ```
*/
hasCode(code: ErrorCode): boolean {
let current: Error | undefined = this;
while (current) {
if (current instanceof AppError && current.code === code) {
return true;
}
current = current instanceof AppError ? current.cause : undefined;
}
return false;
}
/**
* Convert to JSON for API responses.
* Excludes stack traces and internal details.
*/
toJSON(): Record<string, unknown> {
return {
code: this.code,
message: this.message,
httpStatus: this.httpStatus,
retryable: this.retryable,
timestamp: this.timestamp,
...(Object.keys(this.context).length > 0 && { details: this.context }),
};
}
/**
* Convert to JSON for API responses.
* Excludes stack traces and internal details.
*/
toJSON(): Record<string, unknown> {
return {
code: this.code,
message: this.message,
httpStatus: this.httpStatus,
retryable: this.retryable,
timestamp: this.timestamp,
...(Object.keys(this.context).length > 0 && { details: this.context }),
};
}
/**
* Convert to full JSON including stack and cause (for logging).
* Use this for server-side logging, not client responses.
*/
toFullJSON(): Record<string, unknown> {
return {
...this.toJSON(),
stack: this.stack,
cause:
this.cause instanceof AppError
? this.cause.toFullJSON()
: this.cause?.message,
};
}
/**
* Convert to full JSON including stack and cause (for logging).
* Use this for server-side logging, not client responses.
*/
toFullJSON(): Record<string, unknown> {
return {
...this.toJSON(),
stack: this.stack,
cause: this.cause instanceof AppError ? this.cause.toFullJSON() : this.cause?.message,
};
}
}

View file

@ -2,11 +2,11 @@ import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type AuthErrorCode =
| ErrorCode.AUTHENTICATION_REQUIRED
| ErrorCode.INVALID_TOKEN
| ErrorCode.TOKEN_EXPIRED
| ErrorCode.PERMISSION_DENIED
| ErrorCode.RESOURCE_NOT_OWNED;
| ErrorCode.AUTHENTICATION_REQUIRED
| ErrorCode.INVALID_TOKEN
| ErrorCode.TOKEN_EXPIRED
| ErrorCode.PERMISSION_DENIED
| ErrorCode.RESOURCE_NOT_OWNED;
/**
* Error for authentication and authorization failures.
@ -25,55 +25,54 @@ type AuthErrorCode =
* ```
*/
export class AuthError extends AppError {
constructor(code: AuthErrorCode, message: string, context?: ErrorContext) {
super({ code, message, context });
this.name = 'AuthError';
}
constructor(code: AuthErrorCode, message: string, context?: ErrorContext) {
super({ code, message, context });
this.name = 'AuthError';
}
/**
* Create an error for missing authentication.
* HTTP 401 Unauthorized
*/
static unauthorized(message = 'Authentication required'): AuthError {
return new AuthError(ErrorCode.AUTHENTICATION_REQUIRED, message);
}
/**
* Create an error for missing authentication.
* HTTP 401 Unauthorized
*/
static unauthorized(message = 'Authentication required'): AuthError {
return new AuthError(ErrorCode.AUTHENTICATION_REQUIRED, message);
}
/**
* Create an error for an invalid token.
* HTTP 401 Unauthorized
*/
static invalidToken(message = 'Invalid or malformed token'): AuthError {
return new AuthError(ErrorCode.INVALID_TOKEN, message);
}
/**
* Create an error for an invalid token.
* HTTP 401 Unauthorized
*/
static invalidToken(message = 'Invalid or malformed token'): AuthError {
return new AuthError(ErrorCode.INVALID_TOKEN, message);
}
/**
* Create an error for an expired token.
* HTTP 401 Unauthorized
*/
static tokenExpired(message = 'Token has expired'): AuthError {
return new AuthError(ErrorCode.TOKEN_EXPIRED, message);
}
/**
* Create an error for an expired token.
* HTTP 401 Unauthorized
*/
static tokenExpired(message = 'Token has expired'): AuthError {
return new AuthError(ErrorCode.TOKEN_EXPIRED, message);
}
/**
* Create an error for insufficient permissions.
* HTTP 403 Forbidden
*/
static forbidden(message = 'Permission denied'): AuthError {
return new AuthError(ErrorCode.PERMISSION_DENIED, message);
}
/**
* Create an error for insufficient permissions.
* HTTP 403 Forbidden
*/
static forbidden(message = 'Permission denied'): AuthError {
return new AuthError(ErrorCode.PERMISSION_DENIED, message);
}
/**
* Create an error when a user tries to access a resource they don't own.
* HTTP 403 Forbidden
*
* @param resourceType - Type of resource (e.g., 'Story', 'Character')
* @param resourceId - ID of the resource
*/
static notOwned(resourceType: string, resourceId: string): AuthError {
return new AuthError(
ErrorCode.RESOURCE_NOT_OWNED,
`${resourceType} does not belong to you`,
{ resourceType, resourceId }
);
}
/**
* Create an error when a user tries to access a resource they don't own.
* HTTP 403 Forbidden
*
* @param resourceType - Type of resource (e.g., 'Story', 'Character')
* @param resourceId - ID of the resource
*/
static notOwned(resourceType: string, resourceId: string): AuthError {
return new AuthError(ErrorCode.RESOURCE_NOT_OWNED, `${resourceType} does not belong to you`, {
resourceType,
resourceId,
});
}
}

View file

@ -12,24 +12,20 @@ import { AppError } from './app-error';
* ```
*/
export class CreditError extends AppError {
/** Credits required for the operation */
readonly requiredCredits: number;
/** Credits required for the operation */
readonly requiredCredits: number;
/** Credits currently available */
readonly availableCredits: number;
/** Credits currently available */
readonly availableCredits: number;
constructor(
requiredCredits: number,
availableCredits: number,
operation?: string
) {
super({
code: ErrorCode.INSUFFICIENT_CREDITS,
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${availableCredits}`,
context: { requiredCredits, availableCredits, operation },
});
this.name = 'CreditError';
this.requiredCredits = requiredCredits;
this.availableCredits = availableCredits;
}
constructor(requiredCredits: number, availableCredits: number, operation?: string) {
super({
code: ErrorCode.INSUFFICIENT_CREDITS,
message: `Insufficient credits. Required: ${requiredCredits}, Available: ${availableCredits}`,
context: { requiredCredits, availableCredits, operation },
});
this.name = 'CreditError';
this.requiredCredits = requiredCredits;
this.availableCredits = availableCredits;
}
}

View file

@ -17,38 +17,28 @@ type DatabaseErrorCode = ErrorCode.DATABASE_ERROR | ErrorCode.CONSTRAINT_VIOLATI
* ```
*/
export class DatabaseError extends AppError {
constructor(
code: DatabaseErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'DatabaseError';
}
constructor(code: DatabaseErrorCode, message: string, cause?: Error, context?: ErrorContext) {
super({ code, message, cause, context });
this.name = 'DatabaseError';
}
/**
* Create a constraint violation error (e.g., unique constraint).
*
* @param field - The field that violated the constraint
* @param message - Description of the violation
*/
static constraintViolation(field: string, message: string): DatabaseError {
return new DatabaseError(
ErrorCode.CONSTRAINT_VIOLATION,
message,
undefined,
{ field }
);
}
/**
* Create a constraint violation error (e.g., unique constraint).
*
* @param field - The field that violated the constraint
* @param message - Description of the violation
*/
static constraintViolation(field: string, message: string): DatabaseError {
return new DatabaseError(ErrorCode.CONSTRAINT_VIOLATION, message, undefined, { field });
}
/**
* Create a generic database query error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static queryFailed(message: string, cause?: Error): DatabaseError {
return new DatabaseError(ErrorCode.DATABASE_ERROR, message, cause);
}
/**
* Create a generic database query error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static queryFailed(message: string, cause?: Error): DatabaseError {
return new DatabaseError(ErrorCode.DATABASE_ERROR, message, cause);
}
}

View file

@ -1,10 +1,7 @@
import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type NetworkErrorCode =
| ErrorCode.NETWORK_ERROR
| ErrorCode.TIMEOUT
| ErrorCode.CONNECTION_REFUSED;
type NetworkErrorCode = ErrorCode.NETWORK_ERROR | ErrorCode.TIMEOUT | ErrorCode.CONNECTION_REFUSED;
/**
* Error for network-level failures (timeouts, connection issues, etc.).
@ -23,41 +20,33 @@ type NetworkErrorCode =
* ```
*/
export class NetworkError extends AppError {
constructor(
code: NetworkErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'NetworkError';
}
constructor(code: NetworkErrorCode, message: string, cause?: Error, context?: ErrorContext) {
super({ code, message, cause, context });
this.name = 'NetworkError';
}
/**
* Create a timeout error.
*
* @param operation - Description of the operation that timed out
*/
static timeout(operation: string): NetworkError {
return new NetworkError(
ErrorCode.TIMEOUT,
`Operation timed out: ${operation}`,
undefined,
{ operation }
);
}
/**
* Create a timeout error.
*
* @param operation - Description of the operation that timed out
*/
static timeout(operation: string): NetworkError {
return new NetworkError(ErrorCode.TIMEOUT, `Operation timed out: ${operation}`, undefined, {
operation,
});
}
/**
* Create a connection refused error.
*
* @param service - Name of the service that refused connection
*/
static connectionRefused(service: string): NetworkError {
return new NetworkError(
ErrorCode.CONNECTION_REFUSED,
`Connection refused: ${service}`,
undefined,
{ service }
);
}
/**
* Create a connection refused error.
*
* @param service - Name of the service that refused connection
*/
static connectionRefused(service: string): NetworkError {
return new NetworkError(
ErrorCode.CONNECTION_REFUSED,
`Connection refused: ${service}`,
undefined,
{ service }
);
}
}

View file

@ -16,30 +16,26 @@ import { AppError, type ErrorContext } from './app-error';
* ```
*/
export class NotFoundError extends AppError {
constructor(
resourceType: string,
identifier: string,
context?: ErrorContext
) {
super({
code: ErrorCode.RESOURCE_NOT_FOUND,
message: `${resourceType} not found: ${identifier}`,
context: { resourceType, identifier, ...context },
});
this.name = 'NotFoundError';
}
constructor(resourceType: string, identifier: string, context?: ErrorContext) {
super({
code: ErrorCode.RESOURCE_NOT_FOUND,
message: `${resourceType} not found: ${identifier}`,
context: { resourceType, identifier, ...context },
});
this.name = 'NotFoundError';
}
/**
* Create a not found error for a user.
*/
static user(userId: string): NotFoundError {
return new NotFoundError('User', userId);
}
/**
* Create a not found error for a user.
*/
static user(userId: string): NotFoundError {
return new NotFoundError('User', userId);
}
/**
* Create a not found error for any resource type.
*/
static resource(resourceType: string, identifier: string): NotFoundError {
return new NotFoundError(resourceType, identifier);
}
/**
* Create a not found error for any resource type.
*/
static resource(resourceType: string, identifier: string): NotFoundError {
return new NotFoundError(resourceType, identifier);
}
}

View file

@ -16,16 +16,16 @@ import { AppError } from './app-error';
* ```
*/
export class RateLimitError extends AppError {
/** Seconds to wait before retrying (if known) */
readonly retryAfter?: number;
/** Seconds to wait before retrying (if known) */
readonly retryAfter?: number;
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
super({
code: ErrorCode.RATE_LIMIT_EXCEEDED,
message,
context: retryAfter ? { retryAfter } : {},
});
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
super({
code: ErrorCode.RATE_LIMIT_EXCEEDED,
message,
context: retryAfter ? { retryAfter } : {},
});
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}

View file

@ -2,10 +2,10 @@ import { ErrorCode } from '../types/error-codes';
import { AppError, type ErrorContext } from './app-error';
type ServiceErrorCode =
| ErrorCode.INTERNAL_ERROR
| ErrorCode.SERVICE_UNAVAILABLE
| ErrorCode.GENERATION_FAILED
| ErrorCode.EXTERNAL_SERVICE_ERROR;
| ErrorCode.INTERNAL_ERROR
| ErrorCode.SERVICE_UNAVAILABLE
| ErrorCode.GENERATION_FAILED
| ErrorCode.EXTERNAL_SERVICE_ERROR;
/**
* Error for service-level failures (internal errors, external API failures, etc.).
@ -27,77 +27,64 @@ type ServiceErrorCode =
* ```
*/
export class ServiceError extends AppError {
constructor(
code: ServiceErrorCode,
message: string,
cause?: Error,
context?: ErrorContext
) {
super({ code, message, cause, context });
this.name = 'ServiceError';
}
constructor(code: ServiceErrorCode, message: string, cause?: Error, context?: ErrorContext) {
super({ code, message, cause, context });
this.name = 'ServiceError';
}
/**
* Create an error for AI/content generation failures.
*
* @param service - Name of the service (e.g., 'OpenAI', 'Azure OpenAI')
* @param reason - Why the generation failed
* @param cause - Original error if available
*/
static generationFailed(
service: string,
reason: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.GENERATION_FAILED,
`${service} generation failed: ${reason}`,
cause,
{ service }
);
}
/**
* Create an error for AI/content generation failures.
*
* @param service - Name of the service (e.g., 'OpenAI', 'Azure OpenAI')
* @param reason - Why the generation failed
* @param cause - Original error if available
*/
static generationFailed(service: string, reason: string, cause?: Error): ServiceError {
return new ServiceError(
ErrorCode.GENERATION_FAILED,
`${service} generation failed: ${reason}`,
cause,
{ service }
);
}
/**
* Create an error for a service that is temporarily unavailable.
*
* @param service - Name of the unavailable service
*/
static unavailable(service: string): ServiceError {
return new ServiceError(
ErrorCode.SERVICE_UNAVAILABLE,
`${service} is temporarily unavailable`,
undefined,
{ service }
);
}
/**
* Create an error for a service that is temporarily unavailable.
*
* @param service - Name of the unavailable service
*/
static unavailable(service: string): ServiceError {
return new ServiceError(
ErrorCode.SERVICE_UNAVAILABLE,
`${service} is temporarily unavailable`,
undefined,
{ service }
);
}
/**
* Create an error for external API failures.
*
* @param service - Name of the external service
* @param message - Error message or description
* @param cause - Original error if available
*/
static externalError(
service: string,
message: string,
cause?: Error
): ServiceError {
return new ServiceError(
ErrorCode.EXTERNAL_SERVICE_ERROR,
`${service} error: ${message}`,
cause,
{ service }
);
}
/**
* Create an error for external API failures.
*
* @param service - Name of the external service
* @param message - Error message or description
* @param cause - Original error if available
*/
static externalError(service: string, message: string, cause?: Error): ServiceError {
return new ServiceError(
ErrorCode.EXTERNAL_SERVICE_ERROR,
`${service} error: ${message}`,
cause,
{ service }
);
}
/**
* Create an internal server error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static internal(message: string, cause?: Error): ServiceError {
return new ServiceError(ErrorCode.INTERNAL_ERROR, message, cause);
}
/**
* Create an internal server error.
*
* @param message - Description of what went wrong
* @param cause - Original error if available
*/
static internal(message: string, cause?: Error): ServiceError {
return new ServiceError(ErrorCode.INTERNAL_ERROR, message, cause);
}
}

View file

@ -16,44 +16,44 @@ import { AppError, type ErrorContext } from './app-error';
* ```
*/
export class ValidationError extends AppError {
constructor(message: string, context?: ErrorContext) {
super({
code: ErrorCode.VALIDATION_FAILED,
message,
context,
});
this.name = 'ValidationError';
}
constructor(message: string, context?: ErrorContext) {
super({
code: ErrorCode.VALIDATION_FAILED,
message,
context,
});
this.name = 'ValidationError';
}
/**
* Create a validation error for an invalid field value.
*
* @param field - The field name that failed validation
* @param reason - Why the validation failed
*/
static invalidInput(field: string, reason: string): ValidationError {
return new ValidationError(`Invalid ${field}: ${reason}`, { field, reason });
}
/**
* Create a validation error for an invalid field value.
*
* @param field - The field name that failed validation
* @param reason - Why the validation failed
*/
static invalidInput(field: string, reason: string): ValidationError {
return new ValidationError(`Invalid ${field}: ${reason}`, { field, reason });
}
/**
* Create a validation error for a missing required field.
*
* @param field - The field name that is missing
*/
static missingField(field: string): ValidationError {
return new ValidationError(`Missing required field: ${field}`, { field });
}
/**
* Create a validation error for a missing required field.
*
* @param field - The field name that is missing
*/
static missingField(field: string): ValidationError {
return new ValidationError(`Missing required field: ${field}`, { field });
}
/**
* Create a validation error for an invalid format.
*
* @param field - The field name with invalid format
* @param expectedFormat - Description of the expected format
*/
static invalidFormat(field: string, expectedFormat: string): ValidationError {
return new ValidationError(
`Invalid format for ${field}: expected ${expectedFormat}`,
{ field, expectedFormat }
);
}
/**
* Create a validation error for an invalid format.
*
* @param field - The field name with invalid format
* @param expectedFormat - Description of the expected format
*/
static invalidFormat(field: string, expectedFormat: string): ValidationError {
return new ValidationError(`Invalid format for ${field}: expected ${expectedFormat}`, {
field,
expectedFormat,
});
}
}

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