mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
🐛 fix(mana-core-auth): use BASE_URL as JWT issuer for OIDC compatibility
OIDC providers like Synapse expect the JWT issuer claim to match the discovery document's issuer URL. Changed JWT plugin config from JWT_ISSUER to BASE_URL to ensure consistency. Also adds: - @manacore/credit-operations package with operation definitions - @manacore/shared-credit-ui package with React Native and Svelte components - CreditInterceptor and @UseCredits decorator in nestjs-integration - Credit system integration in chat backend
This commit is contained in:
parent
075051a1d4
commit
8cd5021b50
29 changed files with 3351 additions and 329 deletions
|
|
@ -197,35 +197,44 @@ Dieses Dokument beschreibt alle Änderungen, die vor dem Go-Live des `mana-core-
|
||||||
- Alle DTOs
|
- Alle DTOs
|
||||||
|
|
||||||
### 3.3 Docker Optimierung
|
### 3.3 Docker Optimierung
|
||||||
- **Status**: [ ] Offen
|
- **Status**: [x] Erledigt (2026-02-01)
|
||||||
- **Priorität**: 🟡 Mittel
|
- **Priorität**: 🟡 Mittel
|
||||||
- **Problem**: Source code kopiert, tsx in prod, kein .dockerignore
|
- **Problem**: Source code kopiert, tsx in prod, kein .dockerignore
|
||||||
- **Lösung**:
|
- **Lösung**:
|
||||||
- `.dockerignore` erstellen
|
- ✅ `.dockerignore` erstellt (node_modules, tests, docs, IDE files)
|
||||||
- `tsx` aus Production entfernen
|
- ✅ `tsx` aus Production entfernt (Migrations laufen extern)
|
||||||
- Source code nicht kopieren
|
- ✅ Source code wird nicht mehr kopiert (nur dist/)
|
||||||
- **Dateien**:
|
- ✅ Multi-stage Build optimiert
|
||||||
- `.dockerignore`
|
- **Geänderte Dateien**:
|
||||||
- `Dockerfile`
|
- `.dockerignore` - NEU
|
||||||
|
- `Dockerfile` - Optimiert
|
||||||
|
|
||||||
### 3.4 Dependency Cleanup
|
### 3.4 Dependency Cleanup
|
||||||
- **Status**: [ ] Offen
|
- **Status**: [x] Erledigt (2026-02-01)
|
||||||
- **Priorität**: 🟡 Mittel
|
- **Priorität**: 🟡 Mittel
|
||||||
- **Problem**: `jsonwebtoken` UND `jose` (nur jose nötig)
|
- **Problem**: `jsonwebtoken` UND `jose` (nur jose nötig laut CLAUDE.md)
|
||||||
- **Lösung**:
|
- **Lösung**:
|
||||||
- `jsonwebtoken` entfernen
|
- ✅ `jsonwebtoken` aus dependencies entfernt
|
||||||
- Alle Imports prüfen
|
- ✅ `@types/jsonwebtoken` aus devDependencies entfernt
|
||||||
- **Datei**: `package.json`
|
- ✅ RS256 Fallback aus `better-auth.service.ts` entfernt
|
||||||
|
- ✅ JWT-Tests auf `jose` Mock umgestellt
|
||||||
|
- ✅ jose Mock implementiert für Jest (ESM-Kompatibilität)
|
||||||
|
- **Geänderte Dateien**:
|
||||||
|
- `package.json` - jsonwebtoken entfernt
|
||||||
|
- `src/auth/services/better-auth.service.ts` - Fallback entfernt
|
||||||
|
- `src/auth/jwt-validation.spec.ts` - jose statt jsonwebtoken
|
||||||
|
- `test/__mocks__/jose.ts` - NEU (HS256 Mock für Tests)
|
||||||
|
|
||||||
### 3.5 Security Scanning in CI/CD
|
### 3.5 Security Scanning in CI/CD
|
||||||
- **Status**: [ ] Offen
|
- **Status**: [x] Erledigt (2026-02-01)
|
||||||
- **Priorität**: 🟡 Mittel
|
- **Priorität**: 🟡 Mittel
|
||||||
- **Problem**: Keine automatische Security-Prüfung
|
- **Problem**: Keine automatische Security-Prüfung
|
||||||
- **Lösung**:
|
- **Lösung**:
|
||||||
- `npm audit` in CI
|
- ✅ `pnpm audit --audit-level=high` in CI (validate job)
|
||||||
- Dependabot aktivieren
|
- ✅ Dependabot bereits konfiguriert (npm, github-actions, docker)
|
||||||
- SAST Tools (optional)
|
- ✅ Warnung für bekannte vulnerable Pakete (lodash, axios)
|
||||||
- **Datei**: `.github/workflows/ci.yml`
|
- **Geänderte Datei**: `.github/workflows/ci.yml`
|
||||||
|
- **Existierende Datei**: `.github/dependabot.yml` (war bereits vorhanden)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -235,10 +244,11 @@ Dieses Dokument beschreibt alle Änderungen, die vor dem Go-Live des `mana-core-
|
||||||
|-------|----------|----------|-------------|
|
|-------|----------|----------|-------------|
|
||||||
| Phase 1 | 5 | 5 | 100% |
|
| Phase 1 | 5 | 5 | 100% |
|
||||||
| Phase 2 | 6 | 5 | 83% |
|
| Phase 2 | 6 | 5 | 83% |
|
||||||
| Phase 3 | 5 | 0 | 0% |
|
| Phase 3 | 5 | 3 | 60% |
|
||||||
| **Gesamt** | **16** | **10** | **63%** |
|
| **Gesamt** | **16** | **13** | **81%** |
|
||||||
|
|
||||||
**Hinweis:** Phase 2.3 (Grafana Dashboard) ist als separates Task für später markiert.
|
**Hinweis:** Phase 2.3 (Grafana Dashboard) ist als separates Task für später markiert.
|
||||||
|
**Offen:** 3.1 (E2E Tests), 3.2 (OpenAPI/Swagger), 2.3 (Grafana Dashboard)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -257,4 +267,7 @@ Dieses Dokument beschreibt alle Änderungen, die vor dem Go-Live des `mana-core-
|
||||||
| 2026-02-01 | 2.4 Disaster Recovery Dokumentation erstellt |
|
| 2026-02-01 | 2.4 Disaster Recovery Dokumentation erstellt |
|
||||||
| 2026-02-01 | 2.5 Error Tracking: Winston JSON-Logs für Loki/Grafana vorbereitet |
|
| 2026-02-01 | 2.5 Error Tracking: Winston JSON-Logs für Loki/Grafana vorbereitet |
|
||||||
| 2026-02-01 | 2.6 Stripe Validierung: Warnung in env.validation.ts (Teil von 1.2) |
|
| 2026-02-01 | 2.6 Stripe Validierung: Warnung in env.validation.ts (Teil von 1.2) |
|
||||||
|
| 2026-02-01 | 3.3 Docker Optimierung: .dockerignore, tsx entfernt, nur dist/ kopiert |
|
||||||
|
| 2026-02-01 | 3.4 Dependency Cleanup: jsonwebtoken entfernt, jose Mock für Tests |
|
||||||
|
| 2026-02-01 | 3.5 Security Scanning: pnpm audit in CI, Dependabot war bereits aktiv |
|
||||||
|
|
||||||
|
|
|
||||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
|
|
@ -558,6 +558,26 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm run lint || echo "Lint warnings found"
|
run: pnpm run lint || echo "Lint warnings found"
|
||||||
|
|
||||||
|
- name: Security Audit
|
||||||
|
run: |
|
||||||
|
# Run pnpm audit and capture exit code
|
||||||
|
# Exit 0 if only moderate/low vulnerabilities
|
||||||
|
pnpm audit --audit-level=high || {
|
||||||
|
echo "::warning::Security vulnerabilities found. Run 'pnpm audit' locally for details."
|
||||||
|
exit 0 # Don't fail build on audit issues (just warn)
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check for known vulnerable packages
|
||||||
|
run: |
|
||||||
|
# Check for packages with known critical vulnerabilities
|
||||||
|
# This is a basic check - for production, consider Snyk or similar
|
||||||
|
if grep -r "lodash@[0-3]\." pnpm-lock.yaml 2>/dev/null; then
|
||||||
|
echo "::warning::Potentially vulnerable lodash version detected"
|
||||||
|
fi
|
||||||
|
if grep -r "axios@0\.[0-9]\." pnpm-lock.yaml 2>/dev/null; then
|
||||||
|
echo "::warning::Potentially vulnerable axios version detected"
|
||||||
|
fi
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Build Docker images - only changed services
|
# Build Docker images - only changed services
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
"docker:clean": "docker compose down -v --rmi local"
|
"docker:clean": "docker compose down -v --rmi local"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@manacore/credit-operations": "workspace:*",
|
||||||
|
"@manacore/nestjs-integration": "workspace:*",
|
||||||
"@manacore/shared-errors": "workspace:*",
|
"@manacore/shared-errors": "workspace:*",
|
||||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||||
"@manacore/shared-nestjs-health": "workspace:*",
|
"@manacore/shared-nestjs-health": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||||
|
import { ManaCoreModule } from '@manacore/nestjs-integration';
|
||||||
import { DatabaseModule } from './db/database.module';
|
import { DatabaseModule } from './db/database.module';
|
||||||
import { ChatModule } from './chat/chat.module';
|
import { ChatModule } from './chat/chat.module';
|
||||||
import { ConversationModule } from './conversation/conversation.module';
|
import { ConversationModule } from './conversation/conversation.module';
|
||||||
|
|
@ -16,6 +17,15 @@ import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
|
ManaCoreModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
appId: configService.get<string>('APP_ID', 'chat'),
|
||||||
|
serviceKey: configService.get<string>('MANA_CORE_SERVICE_KEY', ''),
|
||||||
|
debug: configService.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
MetricsModule.register({
|
MetricsModule.register({
|
||||||
prefix: 'chat_',
|
prefix: 'chat_',
|
||||||
excludePaths: ['/health'],
|
excludePaths: ['/health'],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { AsyncResult, ok, err, ValidationError, ServiceError } from '@manacore/shared-errors';
|
import { AsyncResult, ok, err, ValidationError, ServiceError } from '@manacore/shared-errors';
|
||||||
|
import {
|
||||||
|
CreditClientService,
|
||||||
|
InsufficientCreditsException,
|
||||||
|
CreditOperationType,
|
||||||
|
CREDIT_COSTS,
|
||||||
|
} from '@manacore/nestjs-integration';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
import { Database } from '../db/connection';
|
import { Database } from '../db/connection';
|
||||||
|
|
@ -20,7 +26,8 @@ export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||||
private readonly ollamaService: OllamaService
|
private readonly ollamaService: OllamaService,
|
||||||
|
private readonly creditClient: CreditClientService
|
||||||
) {
|
) {
|
||||||
// OpenRouter setup (cloud provider)
|
// OpenRouter setup (cloud provider)
|
||||||
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
|
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
|
||||||
|
|
@ -69,21 +76,106 @@ export class ChatService {
|
||||||
return err(ValidationError.invalidInput('modelId', `Model ${dto.modelId} not found`));
|
return err(ValidationError.invalidInput('modelId', `Model ${dto.modelId} not found`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine credit operation type and cost based on model
|
||||||
|
const creditOperation = this.getCreditOperationForModel(model);
|
||||||
|
const creditCost = CREDIT_COSTS[creditOperation];
|
||||||
|
|
||||||
// Log user context for tracking (optional)
|
// Log user context for tracking (optional)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`User ${userId} creating chat completion with model ${dto.modelId} (${model.provider})`
|
`User ${userId} creating chat completion with model ${dto.modelId} (${model.provider}) - cost: ${creditCost} credits`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if user has enough credits (skip in development if no service key)
|
||||||
|
const validation = await this.creditClient.validateCredits(
|
||||||
|
userId,
|
||||||
|
creditOperation,
|
||||||
|
creditCost
|
||||||
|
);
|
||||||
|
if (!validation.hasCredits) {
|
||||||
|
throw new InsufficientCreditsException({
|
||||||
|
requiredCredits: creditCost,
|
||||||
|
availableCredits: validation.availableCredits,
|
||||||
|
creditType: 'user',
|
||||||
|
operation: creditOperation,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to appropriate provider based on model configuration
|
// Route to appropriate provider based on model configuration
|
||||||
|
let result;
|
||||||
switch (model.provider) {
|
switch (model.provider) {
|
||||||
case 'ollama':
|
case 'ollama':
|
||||||
return this.createOllamaCompletion(model, dto);
|
result = await this.createOllamaCompletion(model, dto);
|
||||||
|
break;
|
||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
default:
|
default:
|
||||||
return this.createOpenRouterCompletion(model, dto);
|
result = await this.createOpenRouterCompletion(model, dto);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consume credits after successful completion
|
||||||
|
if (result.ok && userId) {
|
||||||
|
const modelName = this.getModelDisplayName(model);
|
||||||
|
const consumed = await this.creditClient.consumeCredits(
|
||||||
|
userId,
|
||||||
|
creditOperation,
|
||||||
|
creditCost,
|
||||||
|
`Chat with ${modelName}`,
|
||||||
|
{
|
||||||
|
modelId: dto.modelId,
|
||||||
|
provider: model.provider,
|
||||||
|
tokens: result.value.usage?.total_tokens || 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
this.logger.warn(`Failed to consume credits for user ${userId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Consumed ${creditCost} credits for user ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the credit operation type based on the model.
|
||||||
|
*/
|
||||||
|
private getCreditOperationForModel(model: Model): CreditOperationType {
|
||||||
|
const params = model.parameters as { model?: string } | null;
|
||||||
|
const modelName = params?.model?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Local Ollama models - cheapest
|
||||||
|
if (model.provider === 'ollama') {
|
||||||
|
return CreditOperationType.AI_CHAT_OLLAMA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud models - price based on model family
|
||||||
|
if (modelName.includes('gpt-4') || modelName.includes('gpt4')) {
|
||||||
|
return CreditOperationType.AI_CHAT_GPT4;
|
||||||
|
}
|
||||||
|
if (modelName.includes('claude')) {
|
||||||
|
return CreditOperationType.AI_CHAT_CLAUDE;
|
||||||
|
}
|
||||||
|
if (modelName.includes('gemini')) {
|
||||||
|
return CreditOperationType.AI_CHAT_GEMINI;
|
||||||
|
}
|
||||||
|
if (modelName.includes('qwen')) {
|
||||||
|
return CreditOperationType.AI_CHAT_QWEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Gemini pricing for other cloud models
|
||||||
|
return CreditOperationType.AI_CHAT_GEMINI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a display name for the model.
|
||||||
|
*/
|
||||||
|
private getModelDisplayName(model: Model): string {
|
||||||
|
if (model.name) return model.name;
|
||||||
|
const params = model.parameters as { model?: string } | null;
|
||||||
|
return params?.model || model.provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createOllamaCompletion(
|
private async createOllamaCompletion(
|
||||||
|
|
|
||||||
27
packages/credit-operations/package.json
Normal file
27
packages/credit-operations/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "@manacore/credit-operations",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Central credit operation definitions and costs for all Mana apps",
|
||||||
|
"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",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
580
packages/credit-operations/src/index.ts
Normal file
580
packages/credit-operations/src/index.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
/**
|
||||||
|
* @manacore/credit-operations
|
||||||
|
*
|
||||||
|
* Central credit operation definitions for all Mana apps.
|
||||||
|
* This package defines operation types, costs, and helper functions
|
||||||
|
* for the unified credit system across the ecosystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Operation Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All credit operations across the Mana ecosystem.
|
||||||
|
* Operations are categorized by type: AI, productivity (micro), and premium.
|
||||||
|
*/
|
||||||
|
export enum CreditOperationType {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AI Operations (Standard Credits: 1-30)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Chat - AI conversations
|
||||||
|
AI_CHAT_GPT4 = 'ai_chat_gpt4',
|
||||||
|
AI_CHAT_CLAUDE = 'ai_chat_claude',
|
||||||
|
AI_CHAT_GEMINI = 'ai_chat_gemini',
|
||||||
|
AI_CHAT_QWEN = 'ai_chat_qwen',
|
||||||
|
AI_CHAT_OLLAMA = 'ai_chat_ollama',
|
||||||
|
|
||||||
|
// Picture - Image generation
|
||||||
|
AI_IMAGE_GENERATION = 'ai_image_generation',
|
||||||
|
AI_IMAGE_UPSCALE = 'ai_image_upscale',
|
||||||
|
|
||||||
|
// Questions - Research
|
||||||
|
AI_RESEARCH_QUICK = 'ai_research_quick',
|
||||||
|
AI_RESEARCH_DEEP = 'ai_research_deep',
|
||||||
|
|
||||||
|
// NutriPhi - Food analysis
|
||||||
|
AI_FOOD_ANALYSIS = 'ai_food_analysis',
|
||||||
|
|
||||||
|
// ManaDeck - AI deck generation
|
||||||
|
AI_DECK_GENERATION = 'ai_deck_generation',
|
||||||
|
AI_CARD_GENERATION = 'ai_card_generation',
|
||||||
|
|
||||||
|
// Zitare - AI explanations
|
||||||
|
AI_QUOTE_EXPLANATION = 'ai_quote_explanation',
|
||||||
|
|
||||||
|
// General AI features
|
||||||
|
AI_SMART_SCHEDULING = 'ai_smart_scheduling',
|
||||||
|
AI_SUGGESTIONS = 'ai_suggestions',
|
||||||
|
AI_ENRICHMENT = 'ai_enrichment',
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Productivity Operations (Micro Credits: 0.01-0.10)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Todo
|
||||||
|
TASK_CREATE = 'task_create',
|
||||||
|
PROJECT_CREATE = 'project_create',
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
EVENT_CREATE = 'event_create',
|
||||||
|
CALENDAR_CREATE = 'calendar_create',
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
CONTACT_CREATE = 'contact_create',
|
||||||
|
|
||||||
|
// Zitare
|
||||||
|
COLLECTION_CREATE = 'collection_create',
|
||||||
|
|
||||||
|
// Presi
|
||||||
|
PRESENTATION_CREATE = 'presentation_create',
|
||||||
|
SLIDE_CREATE = 'slide_create',
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Premium Features (Standard Credits: 0.5-5)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Sync features
|
||||||
|
CALDAV_SYNC = 'caldav_sync',
|
||||||
|
GOOGLE_SYNC = 'google_sync',
|
||||||
|
CLOUD_SYNC = 'cloud_sync',
|
||||||
|
|
||||||
|
// Import/Export
|
||||||
|
BULK_IMPORT = 'bulk_import',
|
||||||
|
PDF_EXPORT = 'pdf_export',
|
||||||
|
|
||||||
|
// Premium themes
|
||||||
|
PREMIUM_THEME = 'premium_theme',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Credit Costs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit costs for each operation type.
|
||||||
|
* Costs are in Credits (decimal values supported for micro-credits).
|
||||||
|
*/
|
||||||
|
export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
||||||
|
// AI Operations (Standard Credits)
|
||||||
|
[CreditOperationType.AI_CHAT_GPT4]: 5,
|
||||||
|
[CreditOperationType.AI_CHAT_CLAUDE]: 5,
|
||||||
|
[CreditOperationType.AI_CHAT_GEMINI]: 2,
|
||||||
|
[CreditOperationType.AI_CHAT_QWEN]: 2,
|
||||||
|
[CreditOperationType.AI_CHAT_OLLAMA]: 0.1,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_IMAGE_GENERATION]: 10,
|
||||||
|
[CreditOperationType.AI_IMAGE_UPSCALE]: 5,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_RESEARCH_QUICK]: 5,
|
||||||
|
[CreditOperationType.AI_RESEARCH_DEEP]: 25,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_FOOD_ANALYSIS]: 3,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_DECK_GENERATION]: 20,
|
||||||
|
[CreditOperationType.AI_CARD_GENERATION]: 2,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_QUOTE_EXPLANATION]: 2,
|
||||||
|
|
||||||
|
[CreditOperationType.AI_SMART_SCHEDULING]: 2,
|
||||||
|
[CreditOperationType.AI_SUGGESTIONS]: 2,
|
||||||
|
[CreditOperationType.AI_ENRICHMENT]: 2,
|
||||||
|
|
||||||
|
// Productivity Operations (Micro Credits)
|
||||||
|
[CreditOperationType.TASK_CREATE]: 0.02,
|
||||||
|
[CreditOperationType.PROJECT_CREATE]: 0.1,
|
||||||
|
|
||||||
|
[CreditOperationType.EVENT_CREATE]: 0.02,
|
||||||
|
[CreditOperationType.CALENDAR_CREATE]: 0.1,
|
||||||
|
|
||||||
|
[CreditOperationType.CONTACT_CREATE]: 0.02,
|
||||||
|
|
||||||
|
[CreditOperationType.COLLECTION_CREATE]: 0.1,
|
||||||
|
|
||||||
|
[CreditOperationType.PRESENTATION_CREATE]: 0.5,
|
||||||
|
[CreditOperationType.SLIDE_CREATE]: 0.02,
|
||||||
|
|
||||||
|
// Premium Features
|
||||||
|
[CreditOperationType.CALDAV_SYNC]: 0.5,
|
||||||
|
[CreditOperationType.GOOGLE_SYNC]: 0.5,
|
||||||
|
[CreditOperationType.CLOUD_SYNC]: 5, // Monthly
|
||||||
|
|
||||||
|
[CreditOperationType.BULK_IMPORT]: 0.2, // Per 10 items
|
||||||
|
[CreditOperationType.PDF_EXPORT]: 1,
|
||||||
|
|
||||||
|
[CreditOperationType.PREMIUM_THEME]: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Operation Metadata
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category of credit operation for grouping and display.
|
||||||
|
*/
|
||||||
|
export enum CreditCategory {
|
||||||
|
AI = 'ai',
|
||||||
|
PRODUCTIVITY = 'productivity',
|
||||||
|
PREMIUM = 'premium',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata about each operation for UI display and documentation.
|
||||||
|
*/
|
||||||
|
export interface OperationMetadata {
|
||||||
|
/** Human-readable name */
|
||||||
|
name: string;
|
||||||
|
/** Description for tooltips/help */
|
||||||
|
description: string;
|
||||||
|
/** Category for grouping */
|
||||||
|
category: CreditCategory;
|
||||||
|
/** Which app this operation belongs to */
|
||||||
|
app: string;
|
||||||
|
/** Is this a per-item cost (e.g., bulk import per 10 items) */
|
||||||
|
perItem?: boolean;
|
||||||
|
/** Item unit name if perItem is true */
|
||||||
|
itemUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for all operations.
|
||||||
|
*/
|
||||||
|
export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata> = {
|
||||||
|
// AI Chat
|
||||||
|
[CreditOperationType.AI_CHAT_GPT4]: {
|
||||||
|
name: 'GPT-4 Message',
|
||||||
|
description: 'Send a message using GPT-4 or GPT-4o',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'chat',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_CHAT_CLAUDE]: {
|
||||||
|
name: 'Claude Message',
|
||||||
|
description: 'Send a message using Claude (Anthropic)',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'chat',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_CHAT_GEMINI]: {
|
||||||
|
name: 'Gemini Message',
|
||||||
|
description: 'Send a message using Google Gemini',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'chat',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_CHAT_QWEN]: {
|
||||||
|
name: 'Qwen Message',
|
||||||
|
description: 'Send a message using Qwen',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'chat',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_CHAT_OLLAMA]: {
|
||||||
|
name: 'Ollama Message (Local)',
|
||||||
|
description: 'Send a message using local Ollama models',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'chat',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image Generation
|
||||||
|
[CreditOperationType.AI_IMAGE_GENERATION]: {
|
||||||
|
name: 'Generate Image',
|
||||||
|
description: 'Generate an AI image',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'picture',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_IMAGE_UPSCALE]: {
|
||||||
|
name: 'Upscale Image',
|
||||||
|
description: 'Upscale an image to higher resolution',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'picture',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Research
|
||||||
|
[CreditOperationType.AI_RESEARCH_QUICK]: {
|
||||||
|
name: 'Quick Research',
|
||||||
|
description: 'Quick research with 5 sources',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'questions',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_RESEARCH_DEEP]: {
|
||||||
|
name: 'Deep Research',
|
||||||
|
description: 'Comprehensive research with 30+ sources',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'questions',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Food Analysis
|
||||||
|
[CreditOperationType.AI_FOOD_ANALYSIS]: {
|
||||||
|
name: 'Analyze Food Photo',
|
||||||
|
description: 'Analyze nutrition from a food photo',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'nutriphi',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deck Generation
|
||||||
|
[CreditOperationType.AI_DECK_GENERATION]: {
|
||||||
|
name: 'Generate AI Deck',
|
||||||
|
description: 'Generate a complete deck with AI (10 cards)',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'manadeck',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_CARD_GENERATION]: {
|
||||||
|
name: 'Generate AI Card',
|
||||||
|
description: 'Generate a single card with AI',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'manadeck',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quote Explanation
|
||||||
|
[CreditOperationType.AI_QUOTE_EXPLANATION]: {
|
||||||
|
name: 'Explain Quote',
|
||||||
|
description: 'Get an AI explanation of a quote',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'zitare',
|
||||||
|
},
|
||||||
|
|
||||||
|
// General AI
|
||||||
|
[CreditOperationType.AI_SMART_SCHEDULING]: {
|
||||||
|
name: 'Smart Scheduling',
|
||||||
|
description: 'AI-powered task scheduling suggestions',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'todo',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_SUGGESTIONS]: {
|
||||||
|
name: 'AI Suggestions',
|
||||||
|
description: 'Get AI-powered suggestions',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'general',
|
||||||
|
},
|
||||||
|
[CreditOperationType.AI_ENRICHMENT]: {
|
||||||
|
name: 'AI Enrichment',
|
||||||
|
description: 'Enrich data with AI-gathered information',
|
||||||
|
category: CreditCategory.AI,
|
||||||
|
app: 'contacts',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productivity - Todo
|
||||||
|
[CreditOperationType.TASK_CREATE]: {
|
||||||
|
name: 'Create Task',
|
||||||
|
description: 'Create a new task',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'todo',
|
||||||
|
},
|
||||||
|
[CreditOperationType.PROJECT_CREATE]: {
|
||||||
|
name: 'Create Project',
|
||||||
|
description: 'Create a new project',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'todo',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productivity - Calendar
|
||||||
|
[CreditOperationType.EVENT_CREATE]: {
|
||||||
|
name: 'Create Event',
|
||||||
|
description: 'Create a calendar event',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'calendar',
|
||||||
|
},
|
||||||
|
[CreditOperationType.CALENDAR_CREATE]: {
|
||||||
|
name: 'Create Calendar',
|
||||||
|
description: 'Create a new calendar',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'calendar',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productivity - Contacts
|
||||||
|
[CreditOperationType.CONTACT_CREATE]: {
|
||||||
|
name: 'Create Contact',
|
||||||
|
description: 'Create a new contact',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'contacts',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productivity - Zitare
|
||||||
|
[CreditOperationType.COLLECTION_CREATE]: {
|
||||||
|
name: 'Create Collection',
|
||||||
|
description: 'Create a quote collection',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'zitare',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Productivity - Presi
|
||||||
|
[CreditOperationType.PRESENTATION_CREATE]: {
|
||||||
|
name: 'Create Presentation',
|
||||||
|
description: 'Create a new presentation',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'presi',
|
||||||
|
},
|
||||||
|
[CreditOperationType.SLIDE_CREATE]: {
|
||||||
|
name: 'Create Slide',
|
||||||
|
description: 'Add a slide to a presentation',
|
||||||
|
category: CreditCategory.PRODUCTIVITY,
|
||||||
|
app: 'presi',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Premium - Sync
|
||||||
|
[CreditOperationType.CALDAV_SYNC]: {
|
||||||
|
name: 'CalDAV Sync',
|
||||||
|
description: 'Sync with CalDAV server',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'calendar',
|
||||||
|
},
|
||||||
|
[CreditOperationType.GOOGLE_SYNC]: {
|
||||||
|
name: 'Google Sync',
|
||||||
|
description: 'Sync with Google services',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'contacts',
|
||||||
|
},
|
||||||
|
[CreditOperationType.CLOUD_SYNC]: {
|
||||||
|
name: 'Cloud Sync (Monthly)',
|
||||||
|
description: 'Enable cloud synchronization',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'skilltree',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Premium - Import/Export
|
||||||
|
[CreditOperationType.BULK_IMPORT]: {
|
||||||
|
name: 'Bulk Import',
|
||||||
|
description: 'Import items in bulk',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'general',
|
||||||
|
perItem: true,
|
||||||
|
itemUnit: '10 items',
|
||||||
|
},
|
||||||
|
[CreditOperationType.PDF_EXPORT]: {
|
||||||
|
name: 'PDF Export',
|
||||||
|
description: 'Export to PDF format',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'presi',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Premium - Themes
|
||||||
|
[CreditOperationType.PREMIUM_THEME]: {
|
||||||
|
name: 'Premium Theme',
|
||||||
|
description: 'Use a premium theme',
|
||||||
|
category: CreditCategory.PREMIUM,
|
||||||
|
app: 'presi',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the credit cost for an operation.
|
||||||
|
* @param operation The operation type
|
||||||
|
* @returns The cost in credits
|
||||||
|
*/
|
||||||
|
export function getCreditCost(operation: CreditOperationType): number {
|
||||||
|
return CREDIT_COSTS[operation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the metadata for an operation.
|
||||||
|
* @param operation The operation type
|
||||||
|
* @returns Operation metadata
|
||||||
|
*/
|
||||||
|
export function getOperationMetadata(operation: CreditOperationType): OperationMetadata {
|
||||||
|
return OPERATION_METADATA[operation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all operations for a specific app.
|
||||||
|
* @param app The app name (e.g., 'chat', 'todo', 'calendar')
|
||||||
|
* @returns Array of operations for that app
|
||||||
|
*/
|
||||||
|
export function getOperationsForApp(app: string): CreditOperationType[] {
|
||||||
|
return Object.entries(OPERATION_METADATA)
|
||||||
|
.filter(([, meta]) => meta.app === app)
|
||||||
|
.map(([op]) => op as CreditOperationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all operations in a specific category.
|
||||||
|
* @param category The category
|
||||||
|
* @returns Array of operations in that category
|
||||||
|
*/
|
||||||
|
export function getOperationsByCategory(category: CreditCategory): CreditOperationType[] {
|
||||||
|
return Object.entries(OPERATION_METADATA)
|
||||||
|
.filter(([, meta]) => meta.category === category)
|
||||||
|
.map(([op]) => op as CreditOperationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total cost for bulk operations.
|
||||||
|
* @param operation The operation type
|
||||||
|
* @param count Number of items
|
||||||
|
* @returns Total cost in credits
|
||||||
|
*/
|
||||||
|
export function calculateBulkCost(operation: CreditOperationType, count: number): number {
|
||||||
|
const cost = CREDIT_COSTS[operation];
|
||||||
|
const meta = OPERATION_METADATA[operation];
|
||||||
|
|
||||||
|
if (meta.perItem) {
|
||||||
|
// For bulk operations, cost is per batch (e.g., per 10 items)
|
||||||
|
return Math.ceil(count / 10) * cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cost * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an operation is considered "free" (no credit cost).
|
||||||
|
* @param operation The operation type
|
||||||
|
* @returns True if the operation is free
|
||||||
|
*/
|
||||||
|
export function isFreeOperation(operation: CreditOperationType): boolean {
|
||||||
|
return CREDIT_COSTS[operation] === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an operation is a micro-credit operation (< 0.5 credits).
|
||||||
|
* @param operation The operation type
|
||||||
|
* @returns True if micro-credit operation
|
||||||
|
*/
|
||||||
|
export function isMicroCreditOperation(operation: CreditOperationType): boolean {
|
||||||
|
const cost = CREDIT_COSTS[operation];
|
||||||
|
return cost > 0 && cost < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an operation is an AI operation.
|
||||||
|
* @param operation The operation type
|
||||||
|
* @returns True if AI operation
|
||||||
|
*/
|
||||||
|
export function isAiOperation(operation: CreditOperationType): boolean {
|
||||||
|
return OPERATION_METADATA[operation].category === CreditCategory.AI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format credit cost for display.
|
||||||
|
* @param cost The credit cost
|
||||||
|
* @returns Formatted string (e.g., "0.02" or "5")
|
||||||
|
*/
|
||||||
|
export function formatCreditCost(cost: number): string {
|
||||||
|
if (cost === 0) return 'Free';
|
||||||
|
if (cost < 1) return cost.toFixed(2);
|
||||||
|
return cost.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a pricing table for an app (for display in UI).
|
||||||
|
* @param app The app name
|
||||||
|
* @returns Array of pricing entries
|
||||||
|
*/
|
||||||
|
export function getPricingTable(app: string): Array<{
|
||||||
|
operation: CreditOperationType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
formattedCost: string;
|
||||||
|
category: CreditCategory;
|
||||||
|
}> {
|
||||||
|
return getOperationsForApp(app).map((op) => {
|
||||||
|
const meta = OPERATION_METADATA[op];
|
||||||
|
const cost = CREDIT_COSTS[op];
|
||||||
|
return {
|
||||||
|
operation: op,
|
||||||
|
name: meta.name,
|
||||||
|
description: meta.description,
|
||||||
|
cost,
|
||||||
|
formattedCost: formatCreditCost(cost),
|
||||||
|
category: meta.category,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Free Operations List
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations that are always free (no credit cost).
|
||||||
|
* These are read operations, status checks, and engagement actions.
|
||||||
|
*/
|
||||||
|
export const FREE_OPERATIONS = [
|
||||||
|
// Reading/viewing
|
||||||
|
'read',
|
||||||
|
'view',
|
||||||
|
'list',
|
||||||
|
'get',
|
||||||
|
'search',
|
||||||
|
'browse',
|
||||||
|
|
||||||
|
// Task completion (engagement)
|
||||||
|
'complete',
|
||||||
|
'check',
|
||||||
|
'toggle',
|
||||||
|
|
||||||
|
// Editing (no new resource creation)
|
||||||
|
'update',
|
||||||
|
'edit',
|
||||||
|
'modify',
|
||||||
|
|
||||||
|
// Deletion
|
||||||
|
'delete',
|
||||||
|
'remove',
|
||||||
|
'archive',
|
||||||
|
|
||||||
|
// Organization
|
||||||
|
'sort',
|
||||||
|
'filter',
|
||||||
|
'move',
|
||||||
|
'reorder',
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
'tag',
|
||||||
|
'label',
|
||||||
|
'favorite',
|
||||||
|
'unfavorite',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action name represents a free operation.
|
||||||
|
* @param action The action name (e.g., 'update', 'delete')
|
||||||
|
* @returns True if the action is free
|
||||||
|
*/
|
||||||
|
export function isFreeAction(action: string): boolean {
|
||||||
|
const normalizedAction = action.toLowerCase();
|
||||||
|
return FREE_OPERATIONS.some(
|
||||||
|
(freeOp) => normalizedAction === freeOp || normalizedAction.startsWith(`${freeOp}_`)
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/credit-operations/tsconfig.json
Normal file
28
packages/credit-operations/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,11 @@
|
||||||
"types": "./dist/decorators/index.d.ts",
|
"types": "./dist/decorators/index.d.ts",
|
||||||
"import": "./dist/decorators/index.js",
|
"import": "./dist/decorators/index.js",
|
||||||
"require": "./dist/decorators/index.js"
|
"require": "./dist/decorators/index.js"
|
||||||
|
},
|
||||||
|
"./interceptors": {
|
||||||
|
"types": "./dist/interceptors/index.d.ts",
|
||||||
|
"import": "./dist/interceptors/index.js",
|
||||||
|
"require": "./dist/interceptors/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -30,10 +35,12 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@manacore/credit-operations": "workspace:*",
|
||||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
"@nestjs/config": "^3.0.0 || ^4.0.0",
|
"@nestjs/config": "^3.0.0 || ^4.0.0",
|
||||||
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
||||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
"reflect-metadata": "^0.1.13 || ^0.2.0",
|
||||||
|
"rxjs": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { CurrentUser, JwtPayload } from './current-user.decorator';
|
export { CurrentUser, JwtPayload } from './current-user.decorator';
|
||||||
export { Public, IS_PUBLIC_KEY } from './public.decorator';
|
export { Public, IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { CreditInterceptor } from '../interceptors/credit.interceptor';
|
||||||
|
import { type CreditOperationType } from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata key for credit operation configuration.
|
||||||
|
*/
|
||||||
|
export const CREDIT_OPERATION_KEY = 'credit_operation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for credit consumption.
|
||||||
|
*/
|
||||||
|
export interface CreditOperationConfig {
|
||||||
|
/**
|
||||||
|
* The operation type from the credit-operations package.
|
||||||
|
*/
|
||||||
|
operation: CreditOperationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom cost override. If not specified, uses the default from CREDIT_COSTS.
|
||||||
|
*/
|
||||||
|
customCost?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to consume credits before or after the handler execution.
|
||||||
|
* - 'before': Validate and reserve credits before execution (default)
|
||||||
|
* - 'after': Consume credits only after successful execution
|
||||||
|
*/
|
||||||
|
consumeMode?: 'before' | 'after';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional function to calculate cost dynamically based on request.
|
||||||
|
* Receives the request object and should return the credit cost.
|
||||||
|
*/
|
||||||
|
dynamicCost?: (request: any) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional function to generate description for the transaction.
|
||||||
|
* Receives the request object and should return a description string.
|
||||||
|
*/
|
||||||
|
descriptionFn?: (request: any) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to skip the credit check in development mode.
|
||||||
|
* Default: false
|
||||||
|
*/
|
||||||
|
skipInDev?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to require credits for an endpoint.
|
||||||
|
*
|
||||||
|
* @example Simple usage with operation type:
|
||||||
|
* ```typescript
|
||||||
|
* @Post('tasks')
|
||||||
|
* @UseCredits(CreditOperationType.TASK_CREATE)
|
||||||
|
* async createTask(@Body() dto: CreateTaskDto) {
|
||||||
|
* return this.taskService.create(dto);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With configuration object:
|
||||||
|
* ```typescript
|
||||||
|
* @Post('generate')
|
||||||
|
* @UseCredits({
|
||||||
|
* operation: CreditOperationType.AI_IMAGE_GENERATION,
|
||||||
|
* consumeMode: 'after',
|
||||||
|
* descriptionFn: (req) => `Generated image: ${req.body.prompt}`,
|
||||||
|
* })
|
||||||
|
* async generateImage(@Body() dto: GenerateDto) {
|
||||||
|
* return this.imageService.generate(dto);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With dynamic cost:
|
||||||
|
* ```typescript
|
||||||
|
* @Post('bulk-import')
|
||||||
|
* @UseCredits({
|
||||||
|
* operation: CreditOperationType.BULK_IMPORT,
|
||||||
|
* dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2,
|
||||||
|
* })
|
||||||
|
* async bulkImport(@Body() dto: BulkImportDto) {
|
||||||
|
* return this.importService.import(dto);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function UseCredits(
|
||||||
|
operationOrConfig: CreditOperationType | CreditOperationConfig
|
||||||
|
): MethodDecorator {
|
||||||
|
const config: CreditOperationConfig =
|
||||||
|
typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig;
|
||||||
|
|
||||||
|
return applyDecorators(
|
||||||
|
SetMetadata(CREDIT_OPERATION_KEY, config),
|
||||||
|
UseInterceptors(CreditInterceptor)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,14 @@ export { OptionalAuthGuard } from './guards/optional-auth.guard';
|
||||||
// Decorators
|
// Decorators
|
||||||
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
|
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
|
||||||
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
|
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
|
||||||
|
export {
|
||||||
|
UseCredits,
|
||||||
|
CreditOperationConfig,
|
||||||
|
CREDIT_OPERATION_KEY,
|
||||||
|
} from './decorators/use-credits.decorator';
|
||||||
|
|
||||||
|
// Interceptors
|
||||||
|
export { CreditInterceptor } from './interceptors/credit.interceptor';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export {
|
export {
|
||||||
|
|
@ -28,3 +36,18 @@ export {
|
||||||
InsufficientCreditsException,
|
InsufficientCreditsException,
|
||||||
InsufficientCreditsDetails,
|
InsufficientCreditsDetails,
|
||||||
} from './exceptions/insufficient-credits.exception';
|
} from './exceptions/insufficient-credits.exception';
|
||||||
|
|
||||||
|
// Re-export credit operations for convenience
|
||||||
|
export {
|
||||||
|
CreditOperationType,
|
||||||
|
CREDIT_COSTS,
|
||||||
|
CreditCategory,
|
||||||
|
getCreditCost,
|
||||||
|
getOperationMetadata,
|
||||||
|
getOperationsForApp,
|
||||||
|
formatCreditCost,
|
||||||
|
getPricingTable,
|
||||||
|
isFreeOperation,
|
||||||
|
isMicroCreditOperation,
|
||||||
|
isAiOperation,
|
||||||
|
} from '@manacore/credit-operations';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
Logger,
|
||||||
|
Inject,
|
||||||
|
Optional,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Observable, tap, catchError, throwError } from 'rxjs';
|
||||||
|
import { CreditClientService } from '../services/credit-client.service';
|
||||||
|
import {
|
||||||
|
InsufficientCreditsException,
|
||||||
|
InsufficientCreditsDetails,
|
||||||
|
} from '../exceptions/insufficient-credits.exception';
|
||||||
|
import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator';
|
||||||
|
import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations';
|
||||||
|
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
||||||
|
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor that handles credit validation and consumption for decorated endpoints.
|
||||||
|
*
|
||||||
|
* This interceptor:
|
||||||
|
* 1. Checks if the user has sufficient credits before executing the handler
|
||||||
|
* 2. Consumes credits after successful execution (or before, depending on config)
|
||||||
|
* 3. Throws InsufficientCreditsException if the user doesn't have enough credits
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CreditInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(CreditInterceptor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly creditClient: CreditClientService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(MANA_CORE_OPTIONS)
|
||||||
|
private readonly options?: ManaCoreModuleOptions
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const config = this.reflector.get<CreditOperationConfig>(
|
||||||
|
CREDIT_OPERATION_KEY,
|
||||||
|
context.getHandler()
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no config, just proceed (shouldn't happen if decorator is used correctly)
|
||||||
|
if (!config) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!user?.sub) {
|
||||||
|
this.logger.warn('No authenticated user found for credit operation');
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user.sub;
|
||||||
|
const operationName = config.operation;
|
||||||
|
|
||||||
|
// Calculate cost
|
||||||
|
const cost = this.calculateCost(config, request);
|
||||||
|
const consumeMode = config.consumeMode || 'after';
|
||||||
|
|
||||||
|
// Skip in development if configured
|
||||||
|
if (config.skipInDev && this.isDevelopment()) {
|
||||||
|
this.logger.debug(`Skipping credit check in development for ${operationName}`);
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credits before execution
|
||||||
|
const validation = await this.creditClient.validateCredits(userId, operationName, cost);
|
||||||
|
|
||||||
|
if (!validation.hasCredits) {
|
||||||
|
const details: InsufficientCreditsDetails = {
|
||||||
|
requiredCredits: cost,
|
||||||
|
availableCredits: validation.availableCredits,
|
||||||
|
creditType: 'user',
|
||||||
|
operation: operationName,
|
||||||
|
};
|
||||||
|
throw new InsufficientCreditsException(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If consume mode is 'before', consume now
|
||||||
|
if (consumeMode === 'before') {
|
||||||
|
const description = this.generateDescription(config, request);
|
||||||
|
const consumed = await this.creditClient.consumeCredits(
|
||||||
|
userId,
|
||||||
|
operationName,
|
||||||
|
cost,
|
||||||
|
description,
|
||||||
|
this.buildMetadata(config, request)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
this.logger.error(`Failed to consume credits for ${operationName}`);
|
||||||
|
// Still allow the operation to proceed - fail open
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If consume mode is 'after', consume on success
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(async () => {
|
||||||
|
const description = this.generateDescription(config, request);
|
||||||
|
const consumed = await this.creditClient.consumeCredits(
|
||||||
|
userId,
|
||||||
|
operationName,
|
||||||
|
cost,
|
||||||
|
description,
|
||||||
|
this.buildMetadata(config, request)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
this.logger.error(`Failed to consume credits after success for ${operationName}`);
|
||||||
|
} else if (this.options?.debug) {
|
||||||
|
this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
// Don't consume credits if the operation failed
|
||||||
|
this.logger.debug(`Operation ${operationName} failed, credits not consumed`);
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the credit cost for the operation.
|
||||||
|
*/
|
||||||
|
private calculateCost(config: CreditOperationConfig, request: any): number {
|
||||||
|
// Dynamic cost takes priority
|
||||||
|
if (config.dynamicCost) {
|
||||||
|
return config.dynamicCost(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom cost override
|
||||||
|
if (config.customCost !== undefined) {
|
||||||
|
return config.customCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default cost from CREDIT_COSTS
|
||||||
|
return CREDIT_COSTS[config.operation] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a description for the credit transaction.
|
||||||
|
*/
|
||||||
|
private generateDescription(config: CreditOperationConfig, request: any): string {
|
||||||
|
// Custom description function
|
||||||
|
if (config.descriptionFn) {
|
||||||
|
return config.descriptionFn(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default description from operation metadata
|
||||||
|
const metadata = getOperationMetadata(config.operation);
|
||||||
|
return metadata?.name || config.operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build metadata for the credit transaction.
|
||||||
|
*/
|
||||||
|
private buildMetadata(config: CreditOperationConfig, request: any): Record<string, any> {
|
||||||
|
const metadata: Record<string, any> = {
|
||||||
|
operation: config.operation,
|
||||||
|
path: request.path,
|
||||||
|
method: request.method,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add app info from operation metadata
|
||||||
|
const opMeta = getOperationMetadata(config.operation);
|
||||||
|
if (opMeta) {
|
||||||
|
metadata.app = opMeta.app;
|
||||||
|
metadata.category = opMeta.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in development mode.
|
||||||
|
*/
|
||||||
|
private isDevelopment(): boolean {
|
||||||
|
return (
|
||||||
|
this.options?.debug ||
|
||||||
|
process.env.NODE_ENV === 'development' ||
|
||||||
|
process.env.NODE_ENV === 'dev'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { CreditInterceptor } from './credit.interceptor';
|
||||||
51
packages/shared-credit-ui/package.json
Normal file
51
packages/shared-credit-ui/package.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "@manacore/shared-credit-ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Credit system UI components for web (Svelte) and mobile (React Native)",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/web/index.ts",
|
||||||
|
"svelte": "./src/web/index.ts",
|
||||||
|
"default": "./src/web/index.ts"
|
||||||
|
},
|
||||||
|
"./web": {
|
||||||
|
"types": "./src/web/index.ts",
|
||||||
|
"svelte": "./src/web/index.ts",
|
||||||
|
"default": "./src/web/index.ts"
|
||||||
|
},
|
||||||
|
"./mobile": {
|
||||||
|
"types": "./src/mobile/index.ts",
|
||||||
|
"default": "./src/mobile/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"type-check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/credit-operations": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-native": "^0.74.0 || ^0.75.0 || ^0.76.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0 || ^0.81.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.0",
|
||||||
|
"@types/react-native": "^0.73.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
217
packages/shared-credit-ui/src/mobile/CreditBalance.tsx
Normal file
217
packages/shared-credit-ui/src/mobile/CreditBalance.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
|
||||||
|
import { formatCreditCost } from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
interface CreditBalanceProps {
|
||||||
|
/** Current credit balance */
|
||||||
|
balance: number;
|
||||||
|
/** Free credits remaining (optional) */
|
||||||
|
freeCredits?: number;
|
||||||
|
/** Whether the balance is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Callback when "Buy Credits" is pressed */
|
||||||
|
onBuyPress?: () => void;
|
||||||
|
/** Whether to show as compact (header) or expanded */
|
||||||
|
variant?: 'compact' | 'expanded';
|
||||||
|
/** Low balance threshold for warning */
|
||||||
|
lowBalanceThreshold?: number;
|
||||||
|
/** i18n labels */
|
||||||
|
labels?: {
|
||||||
|
credits?: string;
|
||||||
|
freeCredits?: string;
|
||||||
|
buyCredits?: string;
|
||||||
|
lowBalance?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreditBalance({
|
||||||
|
balance,
|
||||||
|
freeCredits = 0,
|
||||||
|
loading = false,
|
||||||
|
onBuyPress,
|
||||||
|
variant = 'compact',
|
||||||
|
lowBalanceThreshold = 10,
|
||||||
|
labels = {},
|
||||||
|
}: CreditBalanceProps) {
|
||||||
|
const {
|
||||||
|
credits: creditsLabel = 'Credits',
|
||||||
|
freeCredits: freeCreditsLabel = 'free',
|
||||||
|
buyCredits: buyCreditsLabel = 'Buy',
|
||||||
|
lowBalance: lowBalanceLabel = 'Low balance',
|
||||||
|
} = labels;
|
||||||
|
|
||||||
|
const totalCredits = balance + freeCredits;
|
||||||
|
const isLowBalance = totalCredits < lowBalanceThreshold;
|
||||||
|
const formattedBalance = formatCreditCost(totalCredits);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, variant === 'compact' ? styles.compact : styles.expanded]}>
|
||||||
|
<ActivityIndicator size="small" color="#3b82f6" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'compact') {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.compactButton, isLowBalance && styles.compactButtonLow]}
|
||||||
|
onPress={onBuyPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.icon, isLowBalance && styles.iconLow]}>⚡</Text>
|
||||||
|
<Text style={[styles.compactValue, isLowBalance && styles.valueLow]}>
|
||||||
|
{formattedBalance}
|
||||||
|
</Text>
|
||||||
|
{onBuyPress && <Text style={styles.plusIcon}>+</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.expanded}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={styles.iconLarge}>⚡</Text>
|
||||||
|
<Text style={styles.title}>{creditsLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.largeValue}>{formattedBalance}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{freeCredits > 0 && (
|
||||||
|
<Text style={styles.freeCredits}>
|
||||||
|
{formatCreditCost(freeCredits)} {freeCreditsLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLowBalance && (
|
||||||
|
<View style={styles.warning}>
|
||||||
|
<Text style={styles.warningIcon}>⚠️</Text>
|
||||||
|
<Text style={styles.warningText}>{lowBalanceLabel}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onBuyPress && (
|
||||||
|
<TouchableOpacity style={styles.buyButton} onPress={onBuyPress} activeOpacity={0.8}>
|
||||||
|
<Text style={styles.buyButtonText}>{buyCreditsLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
expanded: {
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
compactButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
compactButtonLow: {
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
iconLow: {
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
iconLarge: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
compactValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1f2937',
|
||||||
|
},
|
||||||
|
valueLow: {
|
||||||
|
color: '#dc2626',
|
||||||
|
},
|
||||||
|
plusIcon: {
|
||||||
|
marginLeft: 2,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#6b7280',
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
largeValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1f2937',
|
||||||
|
},
|
||||||
|
freeCredits: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6b7280',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
},
|
||||||
|
warningIcon: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#dc2626',
|
||||||
|
},
|
||||||
|
buyButton: {
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#3b82f6',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buyButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CreditBalance;
|
||||||
253
packages/shared-credit-ui/src/mobile/CreditToast.tsx
Normal file
253
packages/shared-credit-ui/src/mobile/CreditToast.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
|
||||||
|
import { formatCreditCost } from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
interface CreditToastProps {
|
||||||
|
/** The operation name or description */
|
||||||
|
operation: string;
|
||||||
|
/** Amount of credits consumed (positive) or refunded (negative) */
|
||||||
|
amount: number;
|
||||||
|
/** Remaining balance after the transaction */
|
||||||
|
remainingBalance?: number;
|
||||||
|
/** Toast type */
|
||||||
|
type?: 'success' | 'error' | 'warning';
|
||||||
|
/** Whether the toast is visible */
|
||||||
|
visible?: boolean;
|
||||||
|
/** Callback when toast should be dismissed */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** Auto-dismiss timeout in ms (0 = no auto-dismiss) */
|
||||||
|
autoDismissMs?: number;
|
||||||
|
/** i18n labels */
|
||||||
|
labels?: {
|
||||||
|
credits?: string;
|
||||||
|
remaining?: string;
|
||||||
|
insufficient?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreditToast({
|
||||||
|
operation,
|
||||||
|
amount,
|
||||||
|
remainingBalance,
|
||||||
|
type = 'success',
|
||||||
|
visible = true,
|
||||||
|
onDismiss,
|
||||||
|
autoDismissMs = 4000,
|
||||||
|
labels = {},
|
||||||
|
}: CreditToastProps) {
|
||||||
|
const {
|
||||||
|
remaining: remainingLabel = 'remaining',
|
||||||
|
insufficient: insufficientLabel = 'Insufficient credits',
|
||||||
|
} = labels;
|
||||||
|
|
||||||
|
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = React.useRef(new Animated.Value(-20)).current;
|
||||||
|
|
||||||
|
const isDeduction = amount > 0;
|
||||||
|
const formattedAmount = formatCreditCost(Math.abs(amount));
|
||||||
|
const formattedRemaining =
|
||||||
|
remainingBalance !== undefined ? formatCreditCost(remainingBalance) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
if (autoDismissMs > 0 && onDismiss) {
|
||||||
|
const timer = setTimeout(onDismiss, autoDismissMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: -20,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [visible, autoDismissMs, onDismiss, fadeAnim, slideAnim]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const getTypeStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
iconBg: styles.iconBgSuccess,
|
||||||
|
icon: '✓',
|
||||||
|
iconColor: '#22c55e',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
iconBg: styles.iconBgError,
|
||||||
|
icon: '✕',
|
||||||
|
iconColor: '#ef4444',
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
iconBg: styles.iconBgWarning,
|
||||||
|
icon: '⚠',
|
||||||
|
iconColor: '#f59e0b',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
iconBg: styles.iconBgSuccess,
|
||||||
|
icon: '✓',
|
||||||
|
iconColor: '#22c55e',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeStyles = getTypeStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ translateY: slideAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.iconWrapper, typeStyles.iconBg]}>
|
||||||
|
<Text style={[styles.iconText, { color: typeStyles.iconColor }]}>{typeStyles.icon}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.operation} numberOfLines={1}>
|
||||||
|
{operation}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.details}>
|
||||||
|
{type === 'error' ? (
|
||||||
|
<Text style={styles.amountError}>{insufficientLabel}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[styles.amount, !isDeduction && styles.amountRefund]}>
|
||||||
|
{isDeduction ? '-' : '+'}
|
||||||
|
{formattedAmount}
|
||||||
|
</Text>
|
||||||
|
{formattedRemaining !== null && (
|
||||||
|
<Text style={styles.remaining}>
|
||||||
|
({formattedRemaining} {remainingLabel})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{onDismiss && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dismissButton}
|
||||||
|
onPress={onDismiss}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.dismissIcon}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 5,
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 400,
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconBgSuccess: {
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
},
|
||||||
|
iconBgError: {
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
},
|
||||||
|
iconBgWarning: {
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
},
|
||||||
|
iconText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
operation: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#1f2937',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
amountRefund: {
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
amountError: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
remaining: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6b7280',
|
||||||
|
},
|
||||||
|
dismissButton: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
dismissIcon: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9ca3af',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CreditToast;
|
||||||
19
packages/shared-credit-ui/src/mobile/index.ts
Normal file
19
packages/shared-credit-ui/src/mobile/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Credit UI components for mobile (React Native)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { CreditBalance } from './CreditBalance';
|
||||||
|
export { CreditToast } from './CreditToast';
|
||||||
|
|
||||||
|
// Re-export useful functions from credit-operations
|
||||||
|
export {
|
||||||
|
formatCreditCost,
|
||||||
|
getCreditCost,
|
||||||
|
getOperationMetadata,
|
||||||
|
getPricingTable,
|
||||||
|
isFreeOperation,
|
||||||
|
isMicroCreditOperation,
|
||||||
|
isAiOperation,
|
||||||
|
CreditOperationType,
|
||||||
|
CreditCategory,
|
||||||
|
} from '@manacore/credit-operations';
|
||||||
339
packages/shared-credit-ui/src/web/CreditBalance.svelte
Normal file
339
packages/shared-credit-ui/src/web/CreditBalance.svelte
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatCreditCost } from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Current credit balance */
|
||||||
|
balance: number;
|
||||||
|
/** Free credits remaining (optional) */
|
||||||
|
freeCredits?: number;
|
||||||
|
/** Whether the balance is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Callback when "Buy Credits" is clicked */
|
||||||
|
onBuyClick?: () => void;
|
||||||
|
/** Whether to show as compact (header) or expanded */
|
||||||
|
variant?: 'compact' | 'expanded';
|
||||||
|
/** Low balance threshold for warning */
|
||||||
|
lowBalanceThreshold?: number;
|
||||||
|
/** i18n labels */
|
||||||
|
creditsLabel?: string;
|
||||||
|
freeCreditsLabel?: string;
|
||||||
|
buyCreditsLabel?: string;
|
||||||
|
lowBalanceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
balance,
|
||||||
|
freeCredits = 0,
|
||||||
|
loading = false,
|
||||||
|
onBuyClick,
|
||||||
|
variant = 'compact',
|
||||||
|
lowBalanceThreshold = 10,
|
||||||
|
creditsLabel = 'Credits',
|
||||||
|
freeCreditsLabel = 'free',
|
||||||
|
buyCreditsLabel = 'Buy',
|
||||||
|
lowBalanceLabel = 'Low balance',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const totalCredits = $derived(balance + freeCredits);
|
||||||
|
const isLowBalance = $derived(totalCredits < lowBalanceThreshold);
|
||||||
|
const formattedBalance = $derived(formatCreditCost(totalCredits));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if variant === 'compact'}
|
||||||
|
<div class="credit-balance credit-balance--compact" class:credit-balance--low={isLowBalance}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="credit-balance__skeleton"></div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="credit-balance__button"
|
||||||
|
onclick={onBuyClick}
|
||||||
|
title={isLowBalance ? lowBalanceLabel : `${formattedBalance} ${creditsLabel}`}
|
||||||
|
>
|
||||||
|
<svg class="credit-balance__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="credit-balance__value">{formattedBalance}</span>
|
||||||
|
{#if onBuyClick}
|
||||||
|
<span class="credit-balance__buy">+</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="credit-balance credit-balance--expanded" class:credit-balance--low={isLowBalance}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="credit-balance__skeleton credit-balance__skeleton--expanded"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="credit-balance__header">
|
||||||
|
<div class="credit-balance__title-row">
|
||||||
|
<svg
|
||||||
|
class="credit-balance__icon credit-balance__icon--large"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="credit-balance__title">{creditsLabel}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="credit-balance__total">
|
||||||
|
<span class="credit-balance__value credit-balance__value--large">{formattedBalance}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if freeCredits > 0}
|
||||||
|
<p class="credit-balance__free">
|
||||||
|
{formatCreditCost(freeCredits)}
|
||||||
|
{freeCreditsLabel}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLowBalance}
|
||||||
|
<div class="credit-balance__warning">
|
||||||
|
<svg class="credit-balance__warning-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{lowBalanceLabel}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if onBuyClick}
|
||||||
|
<button class="credit-balance__buy-button" onclick={onBuyClick}>
|
||||||
|
{buyCreditsLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.credit-balance {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact variant (header) */
|
||||||
|
.credit-balance--compact {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance__button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__button:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance__button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance--low .credit-balance__button {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: rgb(220, 38, 38);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance--low .credit-balance__button {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: rgb(248, 113, 113);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
color: rgb(59, 130, 246);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance--low .credit-balance__icon {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__buy {
|
||||||
|
margin-left: 0.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__button:hover .credit-balance__buy {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded variant */
|
||||||
|
.credit-balance--expanded {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance--expanded {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__icon--large {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__value--large {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__free {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: rgb(220, 38, 38);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance__warning {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: rgb(248, 113, 113);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__warning-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__buy-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgb(59, 130, 246);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__buy-button:hover {
|
||||||
|
background: rgb(37, 99, 235);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading */
|
||||||
|
.credit-balance__skeleton {
|
||||||
|
width: 4rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 0, 0, 0.05) 25%,
|
||||||
|
rgba(0, 0, 0, 0.1) 50%,
|
||||||
|
rgba(0, 0, 0, 0.05) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-balance__skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.05) 25%,
|
||||||
|
rgba(255, 255, 255, 0.1) 50%,
|
||||||
|
rgba(255, 255, 255, 0.05) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-balance__skeleton--expanded {
|
||||||
|
width: 100%;
|
||||||
|
height: 6rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
324
packages/shared-credit-ui/src/web/CreditPricingTable.svelte
Normal file
324
packages/shared-credit-ui/src/web/CreditPricingTable.svelte
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getPricingTable,
|
||||||
|
CreditCategory,
|
||||||
|
type CreditOperationType,
|
||||||
|
} from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The app to show pricing for (e.g., 'todo', 'chat', 'calendar') */
|
||||||
|
app: string;
|
||||||
|
/** Title for the pricing table */
|
||||||
|
title?: string;
|
||||||
|
/** Whether to show category headers */
|
||||||
|
showCategories?: boolean;
|
||||||
|
/** Filter to specific categories */
|
||||||
|
categories?: CreditCategory[];
|
||||||
|
/** i18n labels */
|
||||||
|
operationLabel?: string;
|
||||||
|
costLabel?: string;
|
||||||
|
freeLabel?: string;
|
||||||
|
aiLabel?: string;
|
||||||
|
productivityLabel?: string;
|
||||||
|
premiumLabel?: string;
|
||||||
|
creditsLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
app,
|
||||||
|
title,
|
||||||
|
showCategories = true,
|
||||||
|
categories,
|
||||||
|
operationLabel = 'Operation',
|
||||||
|
costLabel = 'Cost',
|
||||||
|
freeLabel = 'Free',
|
||||||
|
aiLabel = 'AI Features',
|
||||||
|
productivityLabel = 'Create',
|
||||||
|
premiumLabel = 'Premium',
|
||||||
|
creditsLabel = 'Credits',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const allOperations = $derived(getPricingTable(app));
|
||||||
|
|
||||||
|
const filteredOperations = $derived(
|
||||||
|
categories ? allOperations.filter((op) => categories.includes(op.category)) : allOperations
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedOperations = $derived(() => {
|
||||||
|
if (!showCategories) return { all: filteredOperations };
|
||||||
|
|
||||||
|
const groups: Record<string, typeof filteredOperations> = {};
|
||||||
|
|
||||||
|
for (const op of filteredOperations) {
|
||||||
|
const key = op.category;
|
||||||
|
if (!groups[key]) groups[key] = [];
|
||||||
|
groups[key].push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCategoryLabel(category: CreditCategory): string {
|
||||||
|
switch (category) {
|
||||||
|
case CreditCategory.AI:
|
||||||
|
return aiLabel;
|
||||||
|
case CreditCategory.PRODUCTIVITY:
|
||||||
|
return productivityLabel;
|
||||||
|
case CreditCategory.PREMIUM:
|
||||||
|
return premiumLabel;
|
||||||
|
default:
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryIcon(category: CreditCategory): string {
|
||||||
|
switch (category) {
|
||||||
|
case CreditCategory.AI:
|
||||||
|
return 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z';
|
||||||
|
case CreditCategory.PRODUCTIVITY:
|
||||||
|
return 'M12 4.5v15m7.5-7.5h-15';
|
||||||
|
case CreditCategory.PREMIUM:
|
||||||
|
return 'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pricing-table">
|
||||||
|
{#if title}
|
||||||
|
<h3 class="pricing-table__title">{title}</h3>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredOperations.length === 0}
|
||||||
|
<p class="pricing-table__empty">No pricing information available for this app.</p>
|
||||||
|
{:else if showCategories}
|
||||||
|
{@const groups = groupedOperations()}
|
||||||
|
{#each Object.entries(groups) as [category, operations]}
|
||||||
|
<div class="pricing-table__category">
|
||||||
|
<div class="pricing-table__category-header">
|
||||||
|
<svg
|
||||||
|
class="pricing-table__category-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d={getCategoryIcon(category as CreditCategory)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h4 class="pricing-table__category-title">
|
||||||
|
{getCategoryLabel(category as CreditCategory)}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul class="pricing-table__list">
|
||||||
|
{#each operations as op}
|
||||||
|
<li class="pricing-table__item">
|
||||||
|
<div class="pricing-table__item-info">
|
||||||
|
<span class="pricing-table__item-name">{op.name}</span>
|
||||||
|
<span class="pricing-table__item-description">{op.description}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pricing-table__item-cost"
|
||||||
|
class:pricing-table__item-cost--free={op.cost === 0}
|
||||||
|
>
|
||||||
|
{#if op.cost === 0}
|
||||||
|
{freeLabel}
|
||||||
|
{:else}
|
||||||
|
{op.formattedCost}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="pricing-table__simple">
|
||||||
|
<div class="pricing-table__header-row">
|
||||||
|
<span>{operationLabel}</span>
|
||||||
|
<span>{costLabel}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="pricing-table__list">
|
||||||
|
{#each filteredOperations as op}
|
||||||
|
<li class="pricing-table__item">
|
||||||
|
<div class="pricing-table__item-info">
|
||||||
|
<span class="pricing-table__item-name">{op.name}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pricing-table__item-cost"
|
||||||
|
class:pricing-table__item-cost--free={op.cost === 0}
|
||||||
|
>
|
||||||
|
{#if op.cost === 0}
|
||||||
|
{freeLabel}
|
||||||
|
{:else}
|
||||||
|
{op.formattedCost}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="pricing-table__footer">
|
||||||
|
{freeLabel}: Read, edit, delete, and organize items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pricing-table {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .pricing-table {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__category {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__category:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .pricing-table__category-header {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__category-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
color: rgb(59, 130, 246);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__category-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__simple {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .pricing-table__header-row {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .pricing-table__item {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item-cost {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: rgb(59, 130, 246);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__item-cost--free {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-table__footer {
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .pricing-table__footer {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
257
packages/shared-credit-ui/src/web/CreditToast.svelte
Normal file
257
packages/shared-credit-ui/src/web/CreditToast.svelte
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatCreditCost } from '@manacore/credit-operations';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The operation name or description */
|
||||||
|
operation: string;
|
||||||
|
/** Amount of credits consumed (positive) or refunded (negative) */
|
||||||
|
amount: number;
|
||||||
|
/** Remaining balance after the transaction */
|
||||||
|
remainingBalance?: number;
|
||||||
|
/** Toast type */
|
||||||
|
type?: 'success' | 'error' | 'warning';
|
||||||
|
/** Whether the toast is visible */
|
||||||
|
visible?: boolean;
|
||||||
|
/** Callback when toast should be dismissed */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** Auto-dismiss timeout in ms (0 = no auto-dismiss) */
|
||||||
|
autoDismissMs?: number;
|
||||||
|
/** i18n labels */
|
||||||
|
creditsLabel?: string;
|
||||||
|
remainingLabel?: string;
|
||||||
|
insufficientLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
operation,
|
||||||
|
amount,
|
||||||
|
remainingBalance,
|
||||||
|
type = 'success',
|
||||||
|
visible = true,
|
||||||
|
onDismiss,
|
||||||
|
autoDismissMs = 4000,
|
||||||
|
creditsLabel = 'Credits',
|
||||||
|
remainingLabel = 'remaining',
|
||||||
|
insufficientLabel = 'Insufficient credits',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isDeduction = $derived(amount > 0);
|
||||||
|
const formattedAmount = $derived(formatCreditCost(Math.abs(amount)));
|
||||||
|
const formattedRemaining = $derived(
|
||||||
|
remainingBalance !== undefined ? formatCreditCost(remainingBalance) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-dismiss logic
|
||||||
|
$effect(() => {
|
||||||
|
if (visible && autoDismissMs > 0 && onDismiss) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss();
|
||||||
|
}, autoDismissMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
class="credit-toast"
|
||||||
|
class:credit-toast--success={type === 'success'}
|
||||||
|
class:credit-toast--error={type === 'error'}
|
||||||
|
class:credit-toast--warning={type === 'warning'}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div class="credit-toast__icon-wrapper">
|
||||||
|
{#if type === 'success'}
|
||||||
|
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if type === 'error'}
|
||||||
|
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="credit-toast__content">
|
||||||
|
<p class="credit-toast__operation">{operation}</p>
|
||||||
|
<div class="credit-toast__details">
|
||||||
|
{#if type === 'error'}
|
||||||
|
<span class="credit-toast__amount credit-toast__amount--error">{insufficientLabel}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="credit-toast__amount" class:credit-toast__amount--refund={!isDeduction}>
|
||||||
|
{isDeduction ? '-' : '+'}{formattedAmount}
|
||||||
|
</span>
|
||||||
|
{#if formattedRemaining !== null}
|
||||||
|
<span class="credit-toast__remaining">
|
||||||
|
({formattedRemaining}
|
||||||
|
{remainingLabel})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onDismiss}
|
||||||
|
<button class="credit-toast__dismiss" onclick={onDismiss} aria-label="Dismiss">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.credit-toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 400px;
|
||||||
|
animation: slide-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-toast {
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__icon-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast--success .credit-toast__icon-wrapper {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast--error .credit-toast__icon-wrapper {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast--warning .credit-toast__icon-wrapper {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: rgb(245, 158, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__operation {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__amount {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__amount--refund {
|
||||||
|
color: rgb(34, 197, 94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__amount--error {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__remaining {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__dismiss:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .credit-toast__dismiss:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-toast__dismiss svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
packages/shared-credit-ui/src/web/index.ts
Normal file
20
packages/shared-credit-ui/src/web/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Credit UI components for web (Svelte 5)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as CreditBalance } from './CreditBalance.svelte';
|
||||||
|
export { default as CreditToast } from './CreditToast.svelte';
|
||||||
|
export { default as CreditPricingTable } from './CreditPricingTable.svelte';
|
||||||
|
|
||||||
|
// Re-export useful functions from credit-operations
|
||||||
|
export {
|
||||||
|
formatCreditCost,
|
||||||
|
getCreditCost,
|
||||||
|
getOperationMetadata,
|
||||||
|
getPricingTable,
|
||||||
|
isFreeOperation,
|
||||||
|
isMicroCreditOperation,
|
||||||
|
isAiOperation,
|
||||||
|
CreditOperationType,
|
||||||
|
CreditCategory,
|
||||||
|
} from '@manacore/credit-operations';
|
||||||
22
packages/shared-credit-ui/tsconfig.json
Normal file
22
packages/shared-credit-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"jsx": "react-native",
|
||||||
|
"types": ["svelte", "react", "react-native"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
710
pnpm-lock.yaml
generated
710
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^4.1.2",
|
"@nestjs/schedule": "^4.1.2",
|
||||||
|
"@nestjs/swagger": "^8.1.0",
|
||||||
"@nestjs/throttler": "^6.2.1",
|
"@nestjs/throttler": "^6.2.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-auth": "^1.4.3",
|
"better-auth": "^1.4.3",
|
||||||
|
|
@ -43,7 +44,6 @@
|
||||||
"duckdb-async": "^1.1.1",
|
"duckdb-async": "^1.1.1",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
|
@ -64,7 +64,6 @@
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/nodemailer": "^7.0.5",
|
"@types/nodemailer": "^7.0.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { BetterAuthService } from './services/better-auth.service';
|
import { BetterAuthService } from './services/better-auth.service';
|
||||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
|
|
@ -63,6 +64,7 @@ describe('AuthController', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
@ -73,6 +75,8 @@ describe('AuthController', () => {
|
||||||
})
|
})
|
||||||
.overrideGuard(JwtAuthGuard)
|
.overrideGuard(JwtAuthGuard)
|
||||||
.useValue({ canActivate: jest.fn(() => true) })
|
.useValue({ canActivate: jest.fn(() => true) })
|
||||||
|
.overrideGuard(ThrottlerGuard)
|
||||||
|
.useValue({ canActivate: jest.fn(() => true) })
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AuthController>(AuthController);
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,9 @@ export function createBetterAuth(databaseUrl: string) {
|
||||||
*/
|
*/
|
||||||
jwt({
|
jwt({
|
||||||
jwt: {
|
jwt: {
|
||||||
issuer: process.env.JWT_ISSUER || 'manacore',
|
// For OIDC compatibility, issuer MUST match the discovery document
|
||||||
|
// Use BASE_URL to match /.well-known/openid-configuration issuer
|
||||||
|
issuer: process.env.BASE_URL || process.env.JWT_ISSUER || 'http://localhost:3001',
|
||||||
audience: process.env.JWT_AUDIENCE || 'manacore',
|
audience: process.env.JWT_AUDIENCE || 'manacore',
|
||||||
expirationTime: '15m',
|
expirationTime: '15m',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
import { ValidationPipe, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
@ -128,12 +129,69 @@ async function bootstrap() {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Swagger/OpenAPI documentation
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Mana Core Auth API')
|
||||||
|
.setDescription(
|
||||||
|
`
|
||||||
|
## Authentication & Authorization Service
|
||||||
|
|
||||||
|
Mana Core Auth provides centralized authentication for the Mana ecosystem.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **User Authentication**: Registration, login, password reset
|
||||||
|
- **JWT Tokens**: EdDSA-signed access tokens via JWKS
|
||||||
|
- **Organizations (B2B)**: Multi-tenant support with roles
|
||||||
|
- **Credits**: Usage-based credit system
|
||||||
|
- **OIDC Provider**: OAuth2/OpenID Connect for SSO
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Most endpoints require a Bearer token in the Authorization header:
|
||||||
|
\`\`\`
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Rate Limits
|
||||||
|
- Registration: 5 req/min
|
||||||
|
- Login: 10 req/min
|
||||||
|
- Password Reset: 3 req/min
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'Enter your JWT access token',
|
||||||
|
},
|
||||||
|
'JWT-auth'
|
||||||
|
)
|
||||||
|
.addTag('auth', 'User authentication (login, register, logout)')
|
||||||
|
.addTag('organizations', 'B2B organization management')
|
||||||
|
.addTag('credits', 'Credit balance and transactions')
|
||||||
|
.addTag('health', 'Service health checks')
|
||||||
|
.addServer('http://localhost:3001', 'Local Development')
|
||||||
|
.addServer('https://auth.mana.how', 'Production')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api-docs', app, document, {
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
tagsSorter: 'alpha',
|
||||||
|
operationsSorter: 'alpha',
|
||||||
|
},
|
||||||
|
customSiteTitle: 'Mana Core Auth API',
|
||||||
|
});
|
||||||
|
|
||||||
const port = configService.get<number>('port') || 3001;
|
const port = configService.get<number>('port') || 3001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
||||||
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
|
logger.info(`Mana Core Auth running on http://localhost:${port}`, {
|
||||||
port,
|
port,
|
||||||
environment: configService.get<string>('nodeEnv'),
|
environment: configService.get<string>('nodeEnv'),
|
||||||
|
docs: `http://localhost:${port}/api-docs`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,244 @@
|
||||||
/**
|
/**
|
||||||
* Jose Mock - Uses jest.requireActual to get the real module
|
* Jose Mock - Implements basic JWT functions for testing
|
||||||
*
|
*
|
||||||
* We use the real jose library for JWT validation tests
|
* Since jose is an ESM module and jest runs in CommonJS mode,
|
||||||
* since we're testing actual JWT creation and verification.
|
* we need to provide a compatible mock implementation.
|
||||||
|
*
|
||||||
|
* This mock uses Node.js crypto for HS256 signing/verification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Use jest.requireActual to bypass the mock and get the real module
|
import * as crypto from 'crypto';
|
||||||
const actualJose = jest.requireActual('jose');
|
|
||||||
|
|
||||||
export const SignJWT = actualJose.SignJWT;
|
// Error classes matching jose's error types
|
||||||
export const jwtVerify = actualJose.jwtVerify;
|
export const errors = {
|
||||||
export const createRemoteJWKSet = actualJose.createRemoteJWKSet;
|
JWTExpired: class JWTExpired extends Error {
|
||||||
export const errors = actualJose.errors;
|
constructor(message = 'jwt expired') {
|
||||||
export const generateKeyPair = actualJose.generateKeyPair;
|
super(message);
|
||||||
export const exportJWK = actualJose.exportJWK;
|
this.name = 'JWTExpired';
|
||||||
export const importJWK = actualJose.importJWK;
|
}
|
||||||
export const decodeJwt = actualJose.decodeJwt;
|
},
|
||||||
export const decodeProtectedHeader = actualJose.decodeProtectedHeader;
|
JWTClaimValidationFailed: class JWTClaimValidationFailed extends Error {
|
||||||
|
constructor(message = 'jwt claim validation failed') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JWTClaimValidationFailed';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JWSSignatureVerificationFailed: class JWSSignatureVerificationFailed extends Error {
|
||||||
|
constructor(message = 'signature verification failed') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JWSSignatureVerificationFailed';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JWSInvalid: class JWSInvalid extends Error {
|
||||||
|
constructor(message = 'Invalid Compact JWS') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JWSInvalid';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base64url encode/decode utilities
|
||||||
|
function base64urlEncode(data: Buffer | string): string {
|
||||||
|
const buffer = typeof data === 'string' ? Buffer.from(data) : data;
|
||||||
|
return buffer.toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): Buffer {
|
||||||
|
return Buffer.from(str, 'base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignJWT class for creating JWTs
|
||||||
|
export class SignJWT {
|
||||||
|
private payload: Record<string, unknown>;
|
||||||
|
private header: { alg?: string; typ?: string } = { typ: 'JWT' };
|
||||||
|
|
||||||
|
constructor(payload: Record<string, unknown>) {
|
||||||
|
this.payload = { ...payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
setProtectedHeader(header: { alg: string; [key: string]: unknown }): this {
|
||||||
|
this.header = { ...this.header, ...header };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIssuedAt(iat?: number): this {
|
||||||
|
this.payload.iat = iat ?? Math.floor(Date.now() / 1000);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpirationTime(exp: string | number): this {
|
||||||
|
if (typeof exp === 'string') {
|
||||||
|
const match = exp.match(/^(\d+)(s|m|h|d)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, value, unit] = match;
|
||||||
|
const seconds = {
|
||||||
|
s: 1,
|
||||||
|
m: 60,
|
||||||
|
h: 3600,
|
||||||
|
d: 86400,
|
||||||
|
}[unit]!;
|
||||||
|
const iat = (this.payload.iat as number) || Math.floor(Date.now() / 1000);
|
||||||
|
this.payload.exp = iat + parseInt(value) * seconds;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.payload.exp = exp;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIssuer(issuer: string): this {
|
||||||
|
this.payload.iss = issuer;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudience(audience: string): this {
|
||||||
|
this.payload.aud = audience;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotBefore(nbf: number): this {
|
||||||
|
this.payload.nbf = nbf;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sign(secret: Uint8Array): Promise<string> {
|
||||||
|
if (this.header.alg !== 'HS256') {
|
||||||
|
throw new Error(`Unsupported algorithm: ${this.header.alg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerB64 = base64urlEncode(JSON.stringify(this.header));
|
||||||
|
const payloadB64 = base64urlEncode(JSON.stringify(this.payload));
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const hmac = crypto.createHmac('sha256', Buffer.from(secret));
|
||||||
|
hmac.update(signingInput);
|
||||||
|
const signature = hmac.digest();
|
||||||
|
|
||||||
|
return `${signingInput}.${base64urlEncode(signature)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtVerify function for verifying JWTs
|
||||||
|
interface VerifyOptions {
|
||||||
|
algorithms?: string[];
|
||||||
|
issuer?: string;
|
||||||
|
audience?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jwtVerify(
|
||||||
|
token: string,
|
||||||
|
secret: Uint8Array,
|
||||||
|
options: VerifyOptions = {}
|
||||||
|
): Promise<{ payload: Record<string, unknown>; protectedHeader: Record<string, unknown> }> {
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
throw new errors.JWSInvalid('jwt must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new errors.JWSInvalid('Invalid Compact JWS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
|
||||||
|
// Parse header
|
||||||
|
let header: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
header = JSON.parse(base64urlDecode(headerB64).toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new errors.JWSInvalid('Invalid header');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify algorithm
|
||||||
|
if (options.algorithms && !options.algorithms.includes(header.alg as string)) {
|
||||||
|
throw new errors.JWSInvalid('Invalid algorithm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const signingInput = `${headerB64}.${payloadB64}`;
|
||||||
|
const hmac = crypto.createHmac('sha256', Buffer.from(secret));
|
||||||
|
hmac.update(signingInput);
|
||||||
|
const expectedSignature = hmac.digest();
|
||||||
|
const actualSignature = base64urlDecode(signatureB64);
|
||||||
|
|
||||||
|
if (!crypto.timingSafeEqual(expectedSignature, actualSignature)) {
|
||||||
|
throw new errors.JWSSignatureVerificationFailed('signature verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse payload
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new errors.JWSInvalid('Invalid payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate claims
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (payload.exp !== undefined && typeof payload.exp === 'number' && payload.exp <= now) {
|
||||||
|
throw new errors.JWTExpired('jwt expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check not before
|
||||||
|
if (payload.nbf !== undefined && typeof payload.nbf === 'number' && payload.nbf > now) {
|
||||||
|
throw new errors.JWTClaimValidationFailed('jwt not active yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check issuer
|
||||||
|
if (options.issuer !== undefined && payload.iss !== options.issuer) {
|
||||||
|
throw new errors.JWTClaimValidationFailed(`unexpected "iss" claim value`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check audience
|
||||||
|
if (options.audience !== undefined && payload.aud !== options.audience) {
|
||||||
|
throw new errors.JWTClaimValidationFailed(`unexpected "aud" claim value`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, protectedHeader: header };
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRemoteJWKSet mock (returns a verification function)
|
||||||
|
export function createRemoteJWKSet(
|
||||||
|
_url: URL
|
||||||
|
): (header: unknown, token: unknown) => Promise<Uint8Array> {
|
||||||
|
return async () => {
|
||||||
|
throw new Error('Remote JWKS not supported in mock');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKeyPair mock
|
||||||
|
export async function generateKeyPair(
|
||||||
|
_alg: string
|
||||||
|
): Promise<{ publicKey: unknown; privateKey: unknown }> {
|
||||||
|
throw new Error('generateKeyPair not supported in mock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportJWK mock
|
||||||
|
export async function exportJWK(_key: unknown): Promise<unknown> {
|
||||||
|
throw new Error('exportJWK not supported in mock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// importJWK mock
|
||||||
|
export async function importJWK(_jwk: unknown, _alg?: string): Promise<unknown> {
|
||||||
|
throw new Error('importJWK not supported in mock');
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJwt helper
|
||||||
|
export function decodeJwt(token: string): Record<string, unknown> {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new errors.JWSInvalid('Invalid Compact JWS');
|
||||||
|
}
|
||||||
|
return JSON.parse(base64urlDecode(parts[1]).toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeProtectedHeader helper
|
||||||
|
export function decodeProtectedHeader(token: string): Record<string, unknown> {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new errors.JWSInvalid('Invalid Compact JWS');
|
||||||
|
}
|
||||||
|
return JSON.parse(base64urlDecode(parts[0]).toString('utf8'));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue