diff --git a/.claude/plans/local-first-and-service-extraction-overview.md b/.claude/plans/local-first-and-service-extraction-overview.md index 80a8448b6..8b288f754 100644 --- a/.claude/plans/local-first-and-service-extraction-overview.md +++ b/.claude/plans/local-first-and-service-extraction-overview.md @@ -53,74 +53,50 @@ Apps 9-17 und 19 haben die Datenschicht (IndexedDB), aber die Svelte-Stores lese **Ziel:** mana-core-auth aufteilen in fokussierte Microservices auf Hono + Bun. -### Erledigt +### Erledigt — KOMPLETT -| Service | Port | Was extrahiert | LOC (neu) | LOC (entfernt aus Auth) | -| ---------------- | ---- | ---------------------------------------------- | --------- | ----------------------- | -| **mana-credits** | 3061 | Credits, Gifts, Guild Pools, Stripe Payments | ~2.400 | ~4.200 | -| **mana-user** | 3062 | Settings, Tags, Tag-Groups, Tag-Links, Storage | ~780 | ~2.800 | +| Service | Port | Runtime | LOC | Was | +| ---------------------- | ---- | -------- | ------ | --------------------------------------- | +| **mana-auth** | 3001 | Hono+Bun | ~1.900 | Auth, JWT, SSO, OIDC, 2FA, Orgs, Guilds | +| **mana-credits** | 3061 | Hono+Bun | ~2.400 | Credits, Gifts, Guild Pools, Stripe | +| **mana-user** | 3062 | Hono+Bun | ~780 | Settings, Tags, Tag-Groups, Storage | +| **mana-subscriptions** | 3063 | Hono+Bun | ~990 | Plans, Subscriptions, Invoices, Stripe | +| **mana-analytics** | 3064 | Hono+Bun | ~550 | Feedback, Voting, AI Titles | -**Ergebnis:** mana-core-auth von ~20k auf ~13k LOC reduziert. +**Gesamt: ~6.620 LOC** in 5 Hono/Bun Services ersetzt **~20.000 LOC** in 1 NestJS Service. -**Was gemacht wurde:** +**mana-core-auth (NestJS) wurde gelöscht.** mana-auth ist der Drop-in-Ersatz auf Port 3001. -- Neuer Service mit Hono + Bun (kein NestJS) -- Drizzle ORM Schemas adaptiert (keine FK zu Auth-Tabellen) -- Zod statt class-validator für Validation -- JWT-Validierung via JWKS von mana-core-auth +### Was gemacht wurde: + +- 5 eigenständige Hono + Bun Services (kein NestJS mehr) +- Better Auth nativ auf Hono (kein Express↔Fetch-Konvertierung) +- Drizzle ORM Schemas adaptiert (keine FK zwischen Services) +- Zod statt class-validator, jose für JWT - Service-to-Service Auth via X-Service-Key -- CreditClientService URL auf `MANA_CREDITS_URL` umgestellt -- mana-core-auth Registration Hooks auf HTTP-Calls umgestellt -- Docker-Compose Einträge + Cloudflare Tunnel Labels -- Alter Code komplett aus mana-core-auth entfernt - -### Noch zu extrahieren - -| Service | Was | LOC in Auth | Priorität | -| ---------------------- | ------------------------------------ | ----------- | --------- | -| **mana-subscriptions** | Subscriptions, Pläne, Stripe Billing | ~1.100 | Mittel | -| **mana-analytics** | Feedback, Analytics (DuckDB), AI | ~1.000 | Niedrig | - -### Nach vollständiger Extraktion bleibt in mana-core-auth: - -- Better Auth (JWT, Sessions, 2FA, Passkeys, Magic Links) -- OIDC Provider (Matrix/Synapse SSO) -- Organizations (Better Auth Org Plugin) -- Guilds (Org-Wrapper, ohne Pool — Pool ist in mana-credits) -- API Keys -- Security (Audit Logs, Lockout) -- Me (GDPR Export/Delete) -- Health, Metrics +- Docker-Compose für alle Services +- Alter NestJS-Code komplett gelöscht → Geschätzt ~8-10k LOC reines Auth → Dann Hono-Rewrite (Phase 5) --- -## Teil 3: Hono-Rewrite von mana-core-auth (Phase 5) +## Teil 3: Hono-Rewrite von mana-core-auth (Phase 5) — DONE -**Noch nicht begonnen.** Geplante Schritte: +**mana-auth (Hono + Bun) ersetzt mana-core-auth (NestJS).** Alter Code gelöscht. -1. Hono App-Skeleton + Better Auth native Handler -2. JWT Middleware + Auth-Guards als Hono Middleware -3. Health + JWKS + Token-Validation Endpoints -4. Auth-Endpoints (Register, Login, Refresh, SSO) -5. Organizations/Guilds -6. OIDC Provider + Matrix Session -7. API Keys, Me (GDPR), Admin -8. Tests + Umschalten - -**Voraussetzung:** Subscriptions + Analytics zuerst extrahieren. +Fertige Endpoints: Better Auth nativ, Auth (Register/Login/Logout/Validate), Guilds, API Keys, Me (GDPR), Security (Lockout/Audit), OIDC Provider, Login Page. --- -## Teil 4: Infrastruktur (Phase 5b) +## Teil 4: Verbleibende Aufgaben -- [ ] NestJS Dependencies aus dem Monorepo entfernen +- [ ] NestJS Dependencies aus dem Monorepo entfernen (`@nestjs/*`) - [ ] `packages/shared-nestjs-auth` → `packages/shared-hono-auth` - [ ] `@mana-core/nestjs-integration` → `@mana-core/hono-integration` -- [ ] Docker-Images auf Bun Base Image umstellen +- [ ] Store-Migrationen vertiefen (11 Apps: Stores von API → IndexedDB) +- [ ] mana-sync Go Server — Collections aller 19 Apps registrieren - [ ] CI/CD Pipeline anpassen (Go Build + Bun Build) -- [ ] Monitoring: Prometheus Metrics für neue Services - [ ] Load Testing: Sync-Protokoll unter Last testen --- @@ -147,8 +123,10 @@ ef19018e feat(services): create mana-user + remove from auth (-2,834 LOC) ## Nächste Schritte (Priorität) -1. **mana-subscriptions extrahieren** — Stripe Billing raus aus Auth -2. **mana-analytics extrahieren** — Feedback + DuckDB raus aus Auth -3. **Auth Hono-Rewrite** — Better Auth mit nativem Hono-Adapter -4. **Store-Migrationen vertiefen** — Apps 9-17, 19: Stores auf IndexedDB umschreiben +1. ~~mana-subscriptions extrahieren~~ ✅ +2. ~~mana-analytics extrahieren~~ ✅ +3. ~~Auth Hono-Rewrite~~ ✅ +4. **Store-Migrationen vertiefen** — 11 Apps: Stores von API auf IndexedDB umschreiben 5. **mana-sync Go Server** — Collections aller 19 Apps registrieren +6. **NestJS Cleanup** — Dependencies + shared packages migrieren +7. **App-Backend NestJS → Hono** — Chat, Picture, etc. Backends umschreiben diff --git a/CLAUDE.md b/CLAUDE.md index d34905c20..1d1471288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,7 +129,7 @@ manacore-monorepo/ ├── games/ # Game projects │ └── {game-name}/ # Individual games ├── services/ # Standalone microservices -│ ├── mana-core-auth/ # Central authentication service +│ ├── mana-auth/ # Central authentication (Hono + Bun + Better Auth) │ ├── mana-credits/ # Credit system (Hono + Bun, extracted from auth) │ ├── mana-user/ # User settings, tags, storage (Hono + Bun, extracted from auth) │ ├── mana-subscriptions/ # Subscription billing (Hono + Bun, extracted from auth) diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 2fa2b1c84..07ce4954e 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -242,62 +242,35 @@ services: mana-auth: build: - context: . - dockerfile: services/mana-core-auth/Dockerfile - image: mana-core-auth:local - container_name: mana-core-auth + context: services/mana-auth + dockerfile: Dockerfile + image: mana-auth:local + container_name: mana-auth restart: always depends_on: postgres: condition: service_healthy - redis: - condition: service_healthy environment: + TZ: Europe/Berlin NODE_ENV: production PORT: 3001 DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_auth - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} - JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-change-me} - BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}} - JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:-} - JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:-} BASE_URL: https://auth.mana.how - # Cross-domain SSO: share session cookies across all *.mana.how subdomains COOKIE_DOMAIN: .mana.how MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - MANA_CREDITS_URL: http://mana-credits:3002 + MANA_CREDITS_URL: http://mana-credits:3061 + MANA_SUBSCRIPTIONS_URL: http://mana-subscriptions:3063 + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}} SMTP_HOST: smtp-relay.brevo.com SMTP_PORT: 587 SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} - SMTP_PASSWORD: ${SMTP_PASSWORD} - SMTP_FROM: Mana - CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how - DUCKDB_PATH: /data/analytics/metrics.duckdb + SMTP_PASS: ${SMTP_PASSWORD} SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-} - # Backend URLs for user data aggregation (GDPR self-service) - CHAT_BACKEND_URL: http://chat-backend:3030 - TODO_BACKEND_URL: http://todo-backend:3031 - CALENDAR_BACKEND_URL: http://calendar-backend:3032 - CONTACTS_BACKEND_URL: http://contacts-backend:3033 - PICTURE_BACKEND_URL: http://picture-backend:3035 - # PRESI_BACKEND_URL: removed — replaced by Hono server - # ZITARE_BACKEND_URL: removed — migrated to local-first - # PHOTOS_BACKEND_URL: removed — migrated to local-first - # CLOCK_BACKEND_URL: removed — migrated to local-first - STORAGE_BACKEND_URL: http://storage-backend:3034 - ADMIN_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} - MANA_LLM_URL: http://mana-llm:3020 - # WebAuthn / Passkeys - WEBAUTHN_RP_ID: mana.how - WEBAUTHN_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://manadeck.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how - volumes: - - analytics_data:/data/analytics + CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://inventar.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how ports: - "3001:3001" healthcheck: - test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://127.0.0.1:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 120s timeout: 10s retries: 3 diff --git a/services/mana-core-auth/.dockerignore b/services/mana-core-auth/.dockerignore deleted file mode 100644 index 42b1086e6..000000000 --- a/services/mana-core-auth/.dockerignore +++ /dev/null @@ -1,48 +0,0 @@ -# Dependencies -node_modules -.pnpm-store - -# Build output -dist - -# Development files -*.log -*.local -.env -.env.* -!.env.example - -# Test files -coverage -.nyc_output -*.spec.ts -*.test.ts -__tests__ -test - -# IDE -.idea -.vscode -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Git -.git -.gitignore - -# Documentation (not needed in container) -docs -*.md -!README.md - -# Drizzle studio -drizzle - -# Temporary files -tmp -temp -*.tmp diff --git a/services/mana-core-auth/.env.example b/services/mana-core-auth/.env.example deleted file mode 100644 index 0fdee5788..000000000 --- a/services/mana-core-auth/.env.example +++ /dev/null @@ -1,104 +0,0 @@ -# ============================================================================ -# Mana Core Auth - Environment Configuration -# ============================================================================ -# Copy this file to .env and fill in your values. -# Variables marked [REQUIRED] must be set. -# Variables marked [REQUIRED IN PRODUCTION] are optional in development. -# ============================================================================ - -# Environment -NODE_ENV=development -PORT=3001 - -# Logging -# Options: debug, info, warn, error -LOG_LEVEL=debug - -# ============================================================================ -# Database [REQUIRED] -# ============================================================================ -DATABASE_URL=postgresql://manacore:manacore@localhost:5432/manacore_auth - -# ============================================================================ -# Redis (Optional in development, recommended in production) -# ============================================================================ -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= - -# ============================================================================ -# Base URL [REQUIRED IN PRODUCTION] -# ============================================================================ -# The public URL where this auth service is accessible -# Used for email verification links, OIDC callbacks, etc. -BASE_URL=http://localhost:3001 - -# ============================================================================ -# Cross-Domain SSO [REQUIRED IN PRODUCTION] -# ============================================================================ -# Cookie domain for Single Sign-On across subdomains. -# Set to '.mana.how' (with leading dot) to share sessions across: -# - calendar.mana.how -# - todo.mana.how -# - chat.mana.how -# - etc. -# -# Leave empty/unset for local development (cookies will be domain-specific) -COOKIE_DOMAIN= - -# ============================================================================ -# CORS [REQUIRED IN PRODUCTION] -# ============================================================================ -# Comma-separated list of allowed origins -# In development, defaults to localhost ports if not set -CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:8081 - -# ============================================================================ -# JWT Configuration -# ============================================================================ -# Note: Better Auth uses EdDSA keys stored in the database (JWKS). -# These RSA keys are only used as fallback for legacy token generation. -# You can leave these empty if using Better Auth's default JWKS. - -# JWT_PRIVATE_KEY= -# JWT_PUBLIC_KEY= -JWT_ACCESS_TOKEN_EXPIRY=15m -JWT_REFRESH_TOKEN_EXPIRY=7d -JWT_ISSUER=manacore -JWT_AUDIENCE=manacore - -# ============================================================================ -# Stripe (Optional - credit system won't work without it) -# ============================================================================ -# Get your keys from https://dashboard.stripe.com/apikeys -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# ============================================================================ -# SMTP (Optional - emails will be logged if not configured) -# ============================================================================ -# Using Brevo (formerly Sendinblue) SMTP relay -SMTP_HOST=smtp-relay.brevo.com -SMTP_PORT=587 -SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM=ManaCore - -# ============================================================================ -# Credits -# ============================================================================ -CREDITS_SIGNUP_BONUS=150 -CREDITS_DAILY_FREE=5 - -# ============================================================================ -# Rate Limiting -# ============================================================================ -# TTL in seconds, limit is requests per TTL -RATE_LIMIT_TTL=60 -RATE_LIMIT_MAX=100 - -# ============================================================================ -# AI Services (Optional) -# ============================================================================ -GOOGLE_GENAI_API_KEY= diff --git a/services/mana-core-auth/.gitignore b/services/mana-core-auth/.gitignore deleted file mode 100644 index c2d49e8dd..000000000 --- a/services/mana-core-auth/.gitignore +++ /dev/null @@ -1,50 +0,0 @@ -# Dependencies -node_modules/ -.pnpm-store/ - -# Environment -.env -.env.local -.env.production - -# Build output -dist/ -build/ - -# Logs -logs/ -*.log -npm-debug.log* -pnpm-debug.log* - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Keys (NEVER commit these!) -*.pem -private.key -public.key - -# Testing -coverage/ -.nyc_output/ - -# Database -*.db -*.sqlite - -# Misc -.cache/ -tmp/ -temp/ - -# DuckDB local data -data/ diff --git a/services/mana-core-auth/CLAUDE.md b/services/mana-core-auth/CLAUDE.md deleted file mode 100644 index e6cd533d0..000000000 --- a/services/mana-core-auth/CLAUDE.md +++ /dev/null @@ -1,319 +0,0 @@ -# Mana Core Auth - Claude Code Guidelines - -## Project Overview - -Mana Core Auth is the central authentication service for the Mana Universe ecosystem. It uses **Better Auth** for all authentication functionality. - -## ⚠️ CRITICAL RULES FOR CLAUDE CODE - -### 1. ALWAYS USE BETTER AUTH - NO EXCEPTIONS - -**DO NOT** implement custom authentication logic. Better Auth handles: - -- User registration and sign-in -- JWT token generation (EdDSA algorithm) -- JWT token verification (via JWKS) -- Session management -- Organization/multi-tenant support -- Password hashing -- Token refresh - -### 2. JWT Rules - -| DO | DON'T | -| ----------------------------------------- | ----------------------------------- | -| Use `jose` library for JWT operations | Use `jsonwebtoken` library | -| Use Better Auth's JWKS endpoint | Configure RSA keys in `.env` | -| Use EdDSA algorithm (Better Auth default) | Use RS256 or HS256 | -| Fetch JWKS from `/api/v1/auth/jwks` | Hardcode public keys | -| Keep JWT claims minimal | Add credit_balance, org data to JWT | - -### 3. Before Making Auth Changes - -1. **Read the docs first**: `docs/AUTHENTICATION_ARCHITECTURE.md` -2. **Check Better Auth docs**: https://www.better-auth.com/docs -3. **Ask**: "Does Better Auth already provide this?" - Usually YES -4. **Use Context7**: Fetch Better Auth documentation before implementing - -### 4. Token Validation Pattern - -```typescript -// CORRECT - Use jose with JWKS -import { jwtVerify, createRemoteJWKSet } from 'jose'; - -const JWKS = createRemoteJWKSet(new URL('/api/v1/auth/jwks', baseUrl)); -const { payload } = await jwtVerify(token, JWKS, { issuer, audience }); -``` - -```typescript -// WRONG - Never do this -import * as jwt from 'jsonwebtoken'; -jwt.verify(token, publicKey, { algorithms: ['RS256'] }); -``` - -## Tech Stack - -- **Framework**: NestJS 10 -- **Auth**: Better Auth with JWT + Organization plugins -- **Database**: PostgreSQL with Drizzle ORM -- **JWT Library**: `jose` (NOT `jsonwebtoken`) - -## Commands - -```bash -# Development -pnpm start:dev # Start with hot reload - -# Build -pnpm build # Production build - -# Database -pnpm db:push # Push schema to database -pnpm db:generate # Generate migrations -pnpm db:migrate # Run migrations - -# Testing -pnpm test # Unit tests -pnpm test:e2e # E2E tests -``` - -## Project Structure - -``` -services/mana-core-auth/ -├── src/ -│ ├── auth/ -│ │ ├── better-auth.config.ts # Better Auth setup -│ │ ├── services/ -│ │ │ └── better-auth.service.ts # Auth service -│ │ ├── auth.controller.ts # Auth endpoints -│ │ └── dto/ # Request DTOs -│ ├── credits/ # Credit system -│ │ ├── credits.service.ts # Personal credit operations -│ │ ├── guild-pool.service.ts # Guild shared Mana pool -│ │ ├── guild.controller.ts # /credits/guild/* endpoints -│ │ └── dto/ # Credit DTOs (incl. creditSource) -│ ├── guilds/ # Gilden (guild management) -│ │ ├── guilds.controller.ts # /gilden/* endpoints (RPG-branded) -│ │ ├── guilds.service.ts # Wraps Better Auth orgs + sub limits -│ │ └── guilds.module.ts -│ ├── db/ -│ │ ├── schema/ # Drizzle schemas -│ │ │ ├── guilds.schema.ts # guild_pools, spending_limits, transactions -│ │ │ └── ... -│ │ ├── migrations/ # Generated migration files -│ │ ├── connection.ts # DB connection -│ │ └── migrate.ts # Migration script with advisory locks -│ └── config/ -│ └── configuration.ts # App config -├── postgres/init/ -│ ├── 03-organization-rls.sql # Org RLS policies -│ └── 04-guild-rls.sql # Guild pool RLS policies -├── docs/ -│ └── AUTHENTICATION_ARCHITECTURE.md # READ THIS FIRST -└── test/ - └── e2e/ - └── guild-journey.e2e-spec.ts # Full guild E2E tests -``` - -## Gilden (Guilds) - Shared Mana Pools - -Guilds allow users to share a Mana pool (family, friends, teams). Uses Better Auth's organization plugin under the hood. - -### Key Concepts - -- **Gilde** = Organization with a shared credit pool -- **Gildenmeister** = Owner who manages the pool and members -- **Mana-Pool** = Shared credit balance members spend from directly -- **Spending Limits** = Optional per-member daily/monthly limits - -### Endpoints - -**Guild Management** (`/gilden/*`): - -| Method | Endpoint | Who | Description | -|--------|----------|-----|-------------| -| POST | `/gilden` | Auth user | Create guild + pool | -| GET | `/gilden` | Auth user | List user's guilds | -| GET | `/gilden/:id` | Member | Guild details + pool + members | -| PUT | `/gilden/:id` | Owner/Admin | Update guild | -| DELETE | `/gilden/:id` | Owner | Delete guild (cascades pool) | -| POST | `/gilden/:id/invite` | Owner/Admin | Invite member | -| POST | `/gilden/accept-invitation` | Invitee | Accept invitation | -| DELETE | `/gilden/:id/members/:mid` | Owner/Admin | Remove member | -| PUT | `/gilden/:id/members/:mid/role` | Owner/Admin | Change role | - -**Guild Credits** (`/credits/guild/*`): - -| Method | Endpoint | Who | Description | -|--------|----------|-----|-------------| -| GET | `/credits/guild/:id/balance` | Member | Pool balance | -| POST | `/credits/guild/:id/fund` | Owner/Admin | Fund from personal balance | -| POST | `/credits/guild/:id/use` | Member | Use credits from pool | -| GET | `/credits/guild/:id/transactions` | Member | Transaction history | -| GET | `/credits/guild/:id/members/:uid/spending` | Member/Owner | Spending summary | -| GET | `/credits/guild/:id/members/:uid/limits` | Member/Owner | Get limits | -| PUT | `/credits/guild/:id/members/:uid/limits` | Owner/Admin | Set limits | - -**Credit Source Routing**: `POST /credits/use` accepts optional `creditSource`: -```json -{ - "amount": 10, - "appId": "chat", - "description": "AI chat", - "creditSource": { "type": "guild", "guildId": "..." } -} -``` - -### Subscription Limits - -Guild creation and invites respect the user's subscription plan: -- `maxOrganizations` = max guilds a user can own -- `maxTeamMembers` = max members per guild -- Free tier: 1 guild, 1 member (just themselves) - -## Database Migrations - -For comprehensive migration documentation, see **[docs/DATABASE_MIGRATIONS.md](/docs/DATABASE_MIGRATIONS.md)**. - -Key points: -- Use `db:push` for development (fast iteration) -- Use `db:generate` + `db:migrate` for production (tracked migrations) -- Migrations use advisory locks to prevent concurrent execution -- CI/CD runs migrations automatically before code deployment - -## Key Files - -| File | Purpose | -| ------------------------------------------ | ------------------------------------------------ | -| `src/auth/better-auth.config.ts` | Better Auth configuration with JWT + Org plugins | -| `src/auth/services/better-auth.service.ts` | Main auth service - ALL auth logic here | -| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables | -| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation | - -## Environment Variables - -```env -# Required -DATABASE_URL=postgresql://... -JWT_ISSUER=manacore -JWT_AUDIENCE=manacore - -# NOT required for Better Auth JWT (auto-generates EdDSA keys) -# JWT_PRIVATE_KEY=... # DON'T USE - Better Auth uses jwks table -# JWT_PUBLIC_KEY=... # DON'T USE - Better Auth uses jwks table -``` - -## Common Tasks - -### Adding a new auth endpoint - -1. Check if Better Auth already provides it -2. If yes, wrap it in `better-auth.service.ts` -3. Expose via `auth.controller.ts` -4. Add DTO validation - -### Validating tokens from other services - -Other services call `POST /api/v1/auth/validate` with the JWT. The validation uses Better Auth's JWKS (EdDSA keys from `auth.jwks` table). - -### Adding JWT claims - -**DON'T** add dynamic data to JWT claims. Keep them minimal: - -- `sub` (user ID) -- `email` -- `role` -- `sid` (session ID) - -For dynamic data (credits, org info), create API endpoints instead. - -## Debugging - -### Token not validating? - -1. Check algorithm: `echo $TOKEN | cut -d'.' -f1 | base64 -d` - - Should be `EdDSA`, NOT `RS256` -2. Check JWKS endpoint: `curl localhost:3001/api/v1/auth/jwks` -3. Check issuer/audience match between signing and validation - -### User can't sign in? - -1. Check database connection -2. Check `auth.users` table exists -3. Check `auth.accounts` table for credential record - -## Cross-Domain SSO - -Session cookies are shared across all `*.mana.how` subdomains via `COOKIE_DOMAIN=.mana.how`. - -**How it works:** -1. User logs in on any app (e.g., `calendar.mana.how`) -2. Session cookie set with `Domain=.mana.how` -3. User navigates to another app (e.g., `todo.mana.how`) -4. Browser sends the same cookie → User is already authenticated - -**Configuration** (`better-auth.config.ts`): -```typescript -advanced: { - cookiePrefix: 'mana', - crossSubDomainCookies: { - enabled: !!process.env.COOKIE_DOMAIN, - domain: process.env.COOKIE_DOMAIN, // '.mana.how' in production - }, -} -``` - -**Environment Variable:** -- Production: `COOKIE_DOMAIN=.mana.how` -- Development: Leave empty (cookies domain-specific) - -**Adding a new app to SSO** (all 3 steps required): -1. Add `https://{app}.mana.how` to `trustedOrigins` in `better-auth.config.ts` -2. Add `https://{app}.mana.how` to `CORS_ORIGINS` for mana-auth in `docker-compose.macmini.yml` -3. Run `pnpm test -- src/auth/sso-config.spec.ts` to verify alignment (47 contract tests) - -## Test Credentials (Production) - -For automated testing against `auth.mana.how`: - -| Field | Value | -| -------- | -------------------------- | -| Email | `claude-test@mana.how` | -| Password | `ClaudeTest2024` | -| User ID | `kxMeQZSM1HhdiM1ed5EOQ9z0o0aCiXux` | - -**Usage:** -```bash -# Login (returns JWT tokens) -curl -X POST https://auth.mana.how/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"claude-test@mana.how","password":"ClaudeTest2024"}' - -# Login with cookies (Better Auth native - for SSO testing) -curl -c cookies.txt -X POST https://auth.mana.how/api/auth/sign-in/email \ - -H "Content-Type: application/json" \ - -d '{"email":"claude-test@mana.how","password":"ClaudeTest2024"}' - -# Verify cookie has Domain=.mana.how -cat cookies.txt | grep mana.how -``` - -## Testing Auth Flow (Local Development) - -```bash -# Register -curl -X POST http://localhost:3001/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com", "password": "password123", "name": "Test"}' - -# Login -curl -X POST http://localhost:3001/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email": "test@example.com", "password": "password123"}' - -# Validate token -curl -X POST http://localhost:3001/api/v1/auth/validate \ - -H "Content-Type: application/json" \ - -d '{"token": "eyJhbGciOiJFZERTQSIs..."}' -``` diff --git a/services/mana-core-auth/Dockerfile b/services/mana-core-auth/Dockerfile deleted file mode 100644 index 057e41762..000000000 --- a/services/mana-core-auth/Dockerfile +++ /dev/null @@ -1,87 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -# Using node:20-slim instead of alpine for DuckDB glibc compatibility -FROM node:20-slim AS builder - -# Install pnpm (no build tools needed — bcryptjs is pure JS, DuckDB ships prebuilt binaries) -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -# Copy root workspace files -COPY pnpm-workspace.yaml ./ -COPY package.json ./ -COPY pnpm-lock.yaml ./ -COPY patches/ ./patches/ - -# Copy shared packages (required dependencies) -COPY packages/shared-storage ./packages/shared-storage -COPY packages/shared-llm ./packages/shared-llm - -# Copy mana-core-auth -COPY services/mana-core-auth ./services/mana-core-auth - -# Install all dependencies (without ignore-scripts to build native modules like bcrypt) -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --no-frozen-lockfile --filter mana-core-auth... --filter @manacore/shared-storage --filter @manacore/shared-llm - -# Build shared packages first -WORKDIR /app/packages/shared-storage -RUN pnpm build || true - -WORKDIR /app/packages/shared-llm -RUN pnpm build || true - -# Build the application -WORKDIR /app/services/mana-core-auth -RUN pnpm build - -# Remove devDependencies but keep native modules intact -WORKDIR /app -RUN pnpm prune --prod --no-optional 2>/dev/null || true \ - && find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \ - && find node_modules -name '*.map' -delete 2>/dev/null || true \ - && find node_modules -type d -name 'test' -prune -exec rm -rf {} + 2>/dev/null || true \ - && find node_modules -type d -name 'tests' -prune -exec rm -rf {} + 2>/dev/null || true \ - && find node_modules -type d -name '__tests__' -prune -exec rm -rf {} + 2>/dev/null || true \ - && find node_modules -type d -name 'docs' -prune -exec rm -rf {} + 2>/dev/null || true - -# Production stage -# Using node:20-slim instead of alpine for DuckDB glibc compatibility -FROM node:20-slim AS production - -# Install wget for health checks -RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* - -# Create non-root user before copying files -RUN groupadd -g 1001 nodejs && \ - useradd -u 1001 -g nodejs nestjs - -WORKDIR /app - -# Copy files with correct ownership (avoids expensive chown -R layer) -COPY --from=builder --chown=nestjs:nodejs /app/pnpm-workspace.yaml ./ -COPY --from=builder --chown=nestjs:nodejs /app/package.json ./ -COPY --from=builder --chown=nestjs:nodejs /app/pnpm-lock.yaml ./ -COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules -COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages -COPY --from=builder --chown=nestjs:nodejs /app/services/mana-core-auth ./services/mana-core-auth - -# Copy entrypoint script -COPY --chown=nestjs:nodejs services/mana-core-auth/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/services/mana-core-auth - -# Switch to non-root user -USER nestjs - -# Expose port -EXPOSE 3001 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 - -# Start the application -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] diff --git a/services/mana-core-auth/MIGRATIONS.md b/services/mana-core-auth/MIGRATIONS.md deleted file mode 100644 index 27ccfdc0e..000000000 --- a/services/mana-core-auth/MIGRATIONS.md +++ /dev/null @@ -1,74 +0,0 @@ -# Database Setup - Mana Core Auth - -## Overview - -This project uses **Drizzle ORM** with a push-based approach for database schema management. Since this is a greenfield project, we use `db:push` to sync schemas directly to PostgreSQL. - -## Schema Files - -All database tables are defined in TypeScript: - -``` -src/db/schema/ -├── auth.schema.ts # Users, sessions, passwords, 2FA -├── organizations.schema.ts # B2B orgs, members, invitations -├── credits.schema.ts # Balances, transactions, packages -└── index.ts # Export all schemas -``` - -## Commands - -| Command | Description | -| ---------------- | ------------------------------------- | -| `pnpm db:push` | Sync schema to database | -| `pnpm db:studio` | Open Drizzle Studio to view/edit data | - -## First-Time Setup - -### 1. Start PostgreSQL - -```bash -docker compose up postgres -d -``` - -### 2. Push Schema - -```bash -cd services/mana-core-auth -pnpm db:push -``` - -### 3. Apply RLS Policies - -```bash -# These run automatically in Docker, or manually: -psql $DATABASE_URL -f postgres/init/01-init-schemas.sql -psql $DATABASE_URL -f postgres/init/02-init-rls.sql -psql $DATABASE_URL -f postgres/init/03-organization-rls.sql -``` - -## Docker Deployment - -When using Docker Compose, the entrypoint script automatically runs `pnpm db:push --force` before starting the service. No manual intervention needed. - -## Making Schema Changes - -1. Edit the schema files in `src/db/schema/` -2. Run `pnpm db:push` to sync changes -3. Commit schema changes to git - -## Environment Variables - -```env -DATABASE_URL=postgresql://user:password@host:5432/dbname -``` - -## Postgres Init Scripts - -Located in `postgres/init/`: - -- `01-init-schemas.sql` - Creates auth and credits schemas -- `02-init-rls.sql` - Base RLS policies -- `03-organization-rls.sql` - Organization RLS policies - -These run automatically when PostgreSQL container starts for the first time. diff --git a/services/mana-core-auth/QUICKSTART.md b/services/mana-core-auth/QUICKSTART.md deleted file mode 100644 index fe6352d94..000000000 --- a/services/mana-core-auth/QUICKSTART.md +++ /dev/null @@ -1,354 +0,0 @@ -# Quick Start Guide - Mana Core Auth - -Get the authentication system running in 5 minutes! - -## Prerequisites - -- Node.js 20+ -- pnpm 9.15.0+ -- Docker & Docker Compose -- OpenSSL (for key generation) - -## Step 1: Configure Environment (1 minute) - -```bash -# Copy the example -cp .env.example .env - -# Edit .env and add: -# 1. Change default passwords -# 2. Add Stripe test keys (optional for now) -``` - -**Minimum required changes in .env:** - -```env -DATABASE_URL=postgresql://mana:mana@localhost:5432/mana_auth -REDIS_HOST=localhost -``` - -Note: JWT keys are auto-generated by Better Auth (EdDSA algorithm) and stored in the database. - -## Step 2: Start Infrastructure (30 seconds) - -```bash -# From monorepo root -docker-compose up postgres redis -d - -# Wait for services to be healthy -docker-compose ps -``` - -## Step 3: Run Migrations (10 seconds) - -```bash -cd mana-core-auth -pnpm migration:run -``` - -Expected output: - -``` -Running migrations... -Migrations completed successfully -``` - -## Step 4: Start the Service (10 seconds) - -```bash -pnpm start:dev -``` - -You should see: - -``` -🚀 Mana Core Auth running on: http://localhost:3001 -📚 Environment: development -``` - -## Test It Works! - -### 1. Register a User - -```bash -curl -X POST http://localhost:3001/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "test@example.com", - "password": "SecurePass123!", - "name": "Test User" - }' -``` - -Expected response: - -```json -{ - "id": "uuid-here", - "email": "test@example.com", - "name": "Test User", - "createdAt": "2025-11-25T..." -} -``` - -### 2. Login - -```bash -curl -X POST http://localhost:3001/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{ - "email": "test@example.com", - "password": "SecurePass123!" - }' -``` - -Expected response: - -```json -{ - "user": { - "id": "uuid-here", - "email": "test@example.com", - "name": "Test User", - "role": "user" - }, - "accessToken": "eyJhbGciOiJSUzI1NiIs...", - "refreshToken": "long-random-string", - "expiresIn": 900, - "tokenType": "Bearer" -} -``` - -### 3. Check Credit Balance - -```bash -# Replace YOUR_TOKEN with accessToken from login -curl -X GET http://localhost:3001/api/v1/credits/balance \ - -H "Authorization: Bearer YOUR_TOKEN" -``` - -Expected response: - -```json -{ - "balance": 0, - "totalEarned": 0, - "totalSpent": 0 -} -``` - -### 4. Use Some Credits - -First, you'll need to add credits via Stripe or a gift code. Then: - -```bash -curl -X POST http://localhost:3001/api/v1/credits/use \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amount": 10, - "appId": "test", - "description": "Test credit usage", - "idempotencyKey": "test-unique-123" - }' -``` - -Expected response: - -```json -{ - "success": true, - "transaction": { - "id": "uuid-here", - "userId": "uuid-here", - "type": "usage", - "status": "completed", - "amount": -10, - "balanceBefore": 100, - "balanceAfter": 90, - "appId": "test", - "description": "Test credit usage" - }, - "newBalance": { - "balance": 90, - "totalSpent": 10 - } -} -``` - -## You're Done! 🎉 - -The authentication system is now running and ready to use. - -## Next Steps - -1. **Integrate with your apps** - - Add the auth endpoints to your mobile/web apps - - Implement token refresh logic - - Store tokens securely (SecureStore on mobile, httpOnly cookies on web) - -2. **Add Stripe integration** - - Get Stripe API keys - - Add webhook endpoint - - Create credit packages - - Test payment flow - -3. **Production deployment** - - Follow DOCKER_DEPLOYMENT_GUIDE.md - - Set up on VPS - - Configure domain and SSL - - Enable monitoring - -## Troubleshooting - -### "Connection refused" to PostgreSQL - -**Problem:** Database not ready yet - -**Solution:** - -```bash -docker-compose ps # Check if postgres is healthy -docker-compose logs postgres # Check logs -``` - -### "JWKS not found" error - -**Problem:** Better Auth hasn't initialized JWT keys yet - -**Solution:** - -```bash -# Make sure the database is running and migrations have been applied -pnpm db:push - -# The JWKS keys are auto-generated on first request -# Try making a login request to initialize them -``` - -### Migrations fail - -**Problem:** Database schema issues - -**Solution:** - -```bash -# Drop and recreate database -docker-compose down -v -docker-compose up postgres -d -# Wait 10 seconds -pnpm migration:run -``` - -### Port 3001 already in use - -**Problem:** Another service is using the port - -**Solution:** - -```bash -# Change PORT in .env -echo "PORT=3002" >> .env - -# Or kill the process using 3001 -lsof -ti:3001 | xargs kill -``` - -## Development Tips - -### Watch Database Changes - -```bash -pnpm db:studio -# Opens Drizzle Studio at http://localhost:4983 -``` - -### View Logs - -```bash -# Application logs -# The service prints to console when running in dev mode - -# Docker logs -docker-compose logs -f postgres -docker-compose logs -f redis -``` - -### Run Tests - -```bash -pnpm test -pnpm test:watch -pnpm test:cov -``` - -### Format Code - -```bash -pnpm format -pnpm lint -``` - -## Common Commands - -```bash -# Start dev server -pnpm start:dev - -# Build for production -pnpm build - -# Start production server -pnpm start:prod - -# Generate new migration -pnpm migration:generate - -# Run migrations -pnpm migration:run - -# Open database GUI -pnpm db:studio -``` - -## Environment Variables Reference - -### Required - -- `DATABASE_URL` - PostgreSQL connection string - -### Optional (have defaults) - -- `PORT` - Server port (default: 3001) -- `NODE_ENV` - Environment (default: development) -- `REDIS_HOST` - Redis host (default: localhost) -- `CORS_ORIGINS` - Allowed origins (default: localhost:3000,localhost:8081) -- `BASE_URL` - Base URL for JWKS (default: http://localhost:3001) - -### For Production - -- `STRIPE_SECRET_KEY` - Stripe secret key -- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret -- `ACME_EMAIL` - Email for Let's Encrypt SSL -- `AUTH_DOMAIN` - Domain name for the service - -## Resources - -- **Full Documentation:** `README.md` -- **Database Schema:** `docs/DATABASE_SCHEMA.md` -- **Migration Guide:** `MIGRATIONS.md` - -## Support - -If you encounter issues: - -1. Check this guide first -2. Review the logs -3. Consult the master plan -4. Ask the development team - ---- - -**Time to Complete:** ~5 minutes - -**Status:** Ready for Development & Testing diff --git a/services/mana-core-auth/README.md b/services/mana-core-auth/README.md deleted file mode 100644 index a84007cab..000000000 --- a/services/mana-core-auth/README.md +++ /dev/null @@ -1,277 +0,0 @@ -# Mana Core Auth - -Central authentication and credit management system for the Mana Universe ecosystem. - -## Features - -- **JWT-based Authentication** (EdDSA algorithm via Better Auth) - - User registration and login - - Refresh token rotation - - Multi-session management - - JWKS endpoint for token verification - -- **Credit System** - - User balance management - - Transaction ledger (purchase, usage, refund, gift) - - Optimistic locking for concurrency - - Idempotency for credit operations - - Gift code system with auto-redemption on registration - -- **Security** - - Row-Level Security (RLS) on PostgreSQL - - Rate limiting - - CORS protection - - Helmet security headers - - SCRAM-SHA-256 password authentication - -- **Infrastructure** - - Docker-based deployment - - Traefik reverse proxy with automatic SSL - - PgBouncer connection pooling - - Redis caching - - Prometheus + Grafana monitoring - -## Quick Start - -### Development Setup - -1. **Install dependencies** - - ```bash - pnpm install - ``` - -2. **Generate JWT keys** - - ```bash - cd mana-core-auth - ./scripts/generate-keys.sh - ``` - -3. **Set up environment variables** - - ```bash - cp .env.example .env - # Edit .env and add your JWT keys and other configuration - ``` - -4. **Start PostgreSQL and Redis** (using Docker) - - ```bash - docker-compose up postgres redis -d - ``` - -5. **Run migrations** - - ```bash - pnpm migration:generate - pnpm migration:run - ``` - -6. **Start development server** - - ```bash - pnpm start:dev - ``` - - The server will be available at `http://localhost:3001/api/v1` - -### Production Deployment (Docker) - -1. **Set up environment variables** - - ```bash - cp .env.example .env - # Edit .env with production values - ``` - -2. **Generate JWT keys** - - ```bash - ./mana-core-auth/scripts/generate-keys.sh - # Add the generated keys to .env - ``` - -3. **Start all services** - - ```bash - docker-compose up -d - ``` - -4. **Check service health** - ```bash - docker-compose ps - docker-compose logs -f mana-core-auth - ``` - -## API Endpoints - -### Authentication - -**POST** `/api/v1/auth/register` - -- Register a new user -- Body: `{ email, password, name? }` -- Returns: User object - -**POST** `/api/v1/auth/login` - -- Login with email and password -- Body: `{ email, password, deviceId?, deviceName? }` -- Returns: `{ user, accessToken, refreshToken, expiresIn, tokenType }` - -**POST** `/api/v1/auth/refresh` - -- Refresh access token -- Body: `{ refreshToken }` -- Returns: New token pair - -**POST** `/api/v1/auth/logout` - -- Logout and revoke session -- Requires: Bearer token -- Returns: Success message - -**POST** `/api/v1/auth/validate` - -- Validate a JWT token -- Body: `{ token }` -- Returns: `{ valid, payload }` - -### Credits - -**GET** `/api/v1/credits/balance` - -- Get current credit balance -- Requires: Bearer token -- Returns: `{ balance, totalEarned, totalSpent }` - -**POST** `/api/v1/credits/use` - -- Deduct credits from balance -- Requires: Bearer token -- Body: `{ amount, appId, description, idempotencyKey?, metadata? }` -- Returns: Transaction details - -**GET** `/api/v1/credits/transactions?limit=50&offset=0` - -- Get transaction history -- Requires: Bearer token -- Returns: Array of transactions - -**GET** `/api/v1/credits/purchases` - -- Get purchase history -- Requires: Bearer token -- Returns: Array of purchases - -**GET** `/api/v1/credits/packages` - -- Get available credit packages -- Requires: Bearer token -- Returns: Array of packages - -## Database Schema - -### Auth Schema - -- `auth.users` - User accounts -- `auth.sessions` - Active sessions -- `auth.passwords` - Hashed passwords -- `auth.accounts` - OAuth provider accounts -- `auth.verification_tokens` - Email verification & password reset -- `auth.two_factor_auth` - 2FA configuration -- `auth.security_events` - Security audit log - -### Credits Schema - -- `credits.balances` - User credit balances -- `credits.transactions` - Transaction ledger -- `credits.packages` - Available credit packages -- `credits.purchases` - Purchase history -- `credits.usage_stats` - Usage analytics - -## Environment Variables - -See `.env.example` for all available configuration options. - -Key variables: - -- `DATABASE_URL` - PostgreSQL connection string -- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` - Redis configuration -- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` - Stripe integration -- `CORS_ORIGINS` - Allowed origins for CORS -- `BASE_URL` - Base URL for JWKS endpoint (e.g., http://localhost:3001) - -## Development - -### Available Scripts - -```bash -# Start development server with hot-reload -pnpm start:dev - -# Build for production -pnpm build - -# Start production server -pnpm start:prod - -# Run tests -pnpm test - -# Generate database migration -pnpm migration:generate - -# Run migrations -pnpm migration:run - -# Open Drizzle Studio (database GUI) -pnpm db:studio - -# Lint and format -pnpm lint -pnpm format -``` - -## Architecture - -### Token Flow - -1. User registers/logs in → Receives `accessToken` (15min) + `refreshToken` (7 days) -2. Client stores tokens securely (httpOnly cookies on web, SecureStore on mobile) -3. Client includes `Authorization: Bearer ` in requests -4. When access token expires, client uses refresh token to get new pair -5. Refresh tokens are single-use (rotation for security) - -### Credit System - -- **Paid Credits**: Purchased via Stripe (100 mana = €1) -- **Gift Codes**: Can be created and redeemed, auto-redeem on registration if pending -- **Transaction Types**: purchase, usage, refund, gift -- **Idempotency**: Duplicate requests with same key are detected and ignored -- **Concurrency**: Optimistic locking prevents race conditions - -## Security Considerations - -1. **JWT Keys**: Better Auth auto-generates EdDSA keys stored in `auth.jwks` table -2. **Database**: Use strong passwords and enable SSL in production -3. **Redis**: Always set a password for Redis -4. **CORS**: Only allow trusted origins -5. **Rate Limiting**: Configured via Traefik and NestJS throttler -6. **RLS Policies**: Enforce data isolation at database level -7. **HTTPS**: Always use SSL/TLS in production (via Traefik) - -## Monitoring - -- **Prometheus**: Available at `http://localhost:9090` -- **Grafana**: Available at `http://localhost:3000` -- **Logs**: `docker-compose logs -f mana-core-auth` - -## License - -Private - Mana Universe - -## Support - -For issues and questions, contact the development team. diff --git a/services/mana-core-auth/auth.ts b/services/mana-core-auth/auth.ts deleted file mode 100644 index 3e473a6d8..000000000 --- a/services/mana-core-auth/auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Better Auth CLI configuration file - * This file is used by the Better Auth CLI to generate the schema. - * Run: npx @better-auth/cli generate --output ./src/db/schema/better-auth-schema.ts - */ - -import { betterAuth } from 'better-auth'; -import { organization } from 'better-auth/plugins/organization'; -import { jwt } from 'better-auth/plugins/jwt'; - -export const auth = betterAuth({ - // Use simple URL-based connection for CLI - database: { - type: 'postgres', - url: 'postgresql://manacore:devpassword@localhost:5432/manacore', - }, - emailAndPassword: { - enabled: true, - }, - plugins: [ - organization({ - allowUserToCreateOrganization: true, - }), - jwt(), - ], -}); diff --git a/services/mana-core-auth/docker-entrypoint.sh b/services/mana-core-auth/docker-entrypoint.sh deleted file mode 100755 index 59dfc9cdb..000000000 --- a/services/mana-core-auth/docker-entrypoint.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -e - -# Skip migrations in Docker - tables are managed via 'pnpm db:push' locally -# For fresh databases, run 'pnpm db:push' manually first -echo "📋 Skipping migrations (run 'pnpm db:push' locally if needed)" - -# Start the application -echo "🚀 Starting Mana Core Auth..." -exec node dist/main.js diff --git a/services/mana-core-auth/docs/AUTHENTICATION_ARCHITECTURE.md b/services/mana-core-auth/docs/AUTHENTICATION_ARCHITECTURE.md deleted file mode 100644 index 7fcb3e2b8..000000000 --- a/services/mana-core-auth/docs/AUTHENTICATION_ARCHITECTURE.md +++ /dev/null @@ -1,366 +0,0 @@ -# Authentication Architecture - -> **Decision Date**: December 2024 -> **Status**: Active -> **Last Updated**: February 16, 2026 - -## Overview - -Mana Core Auth uses [Better Auth](https://www.better-auth.com/) as the authentication framework. This document explains the architecture, common pitfalls, and how to correctly implement authentication. - ---- - -## ⚠️ CRITICAL: Always Use Better Auth Native Features - -**DO NOT** implement custom JWT signing/verification. Better Auth handles everything. - -### Better Auth Provides: -- ✅ JWT signing with EdDSA (via JWT plugin) -- ✅ JWKS endpoint for public keys -- ✅ Session management -- ✅ Organization/multi-tenant support -- ✅ Token refresh - -### DO NOT: -- ❌ Use `jsonwebtoken` library for signing (Better Auth uses `jose` with EdDSA) -- ❌ Configure RS256 keys in `.env` (Better Auth uses EdDSA with auto-generated keys) -- ❌ Implement custom JWKS endpoints (Better Auth exposes `/api/auth/jwks`) -- ❌ Store JWT keys manually (Better Auth stores them in `jwks` table) - ---- - -## Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ MANA CORE AUTH │ -│ (localhost:3001) │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ -│ │ Better Auth │ │ JWT Plugin │ │ Organization │ │ -│ │ (Core) │ │ (EdDSA) │ │ Plugin │ │ -│ │ │ │ │ │ │ │ -│ │ - Sign Up │ │ - Sign JWT │ │ - Create Org │ │ -│ │ - Sign In │ │ - Verify JWT │ │ - Invite │ │ -│ │ - Sessions │ │ - JWKS Endpoint │ │ - Roles │ │ -│ └─────────────────┘ └──────────────────┘ └────────────────┘ │ -│ │ │ │ │ -│ └──────────────────────┼──────────────────────┘ │ -│ │ │ -│ ┌─────────────▼─────────────┐ │ -│ │ PostgreSQL (auth) │ │ -│ │ │ │ -│ │ - users │ │ -│ │ - sessions │ │ -│ │ - accounts │ │ -│ │ - jwks (EdDSA keys) │ │ -│ │ - organizations │ │ -│ │ - members │ │ -│ └───────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - │ JWT (EdDSA) - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ CLIENT SERVICES │ -│ (Chat Backend, Mobile App, Web App) │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. Client sends JWT in Authorization header │ -│ 2. Service calls POST /api/v1/auth/validate │ -│ 3. mana-core-auth verifies via JWKS (EdDSA) │ -│ 4. Returns { valid: true, payload: {...} } │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## JWT Configuration - -### Better Auth JWT Plugin (EdDSA - DEFAULT) - -Better Auth's JWT plugin uses **EdDSA** algorithm by default with auto-generated keys stored in the `jwks` table. - -```typescript -// src/auth/better-auth.config.ts -jwt({ - jwt: { - issuer: process.env.JWT_ISSUER || 'manacore', - audience: process.env.JWT_AUDIENCE || 'manacore', - expirationTime: '15m', - - definePayload({ user, session }) { - return { - sub: user.id, - email: user.email, - role: user.role || 'user', - sid: session.id, - }; - }, - }, -}), -``` - -### JWT Claims (Minimal) - -**ONLY these claims should be in the JWT:** - -```typescript -{ - sub: string; // User ID - email: string; // User email - role: string; // User role (user, admin, service) - sid: string; // Session ID for reference - iss: string; // Issuer (manacore) - aud: string; // Audience (manacore) - exp: number; // Expiration timestamp -} -``` - -**DO NOT add:** -- `credit_balance` - Changes too frequently, fetch via API -- `organization` - Use Better Auth org plugin APIs -- `customer_type` - Derive from `activeOrganizationId` -- `permissions` - Fetch from org membership API - ---- - -## Token Validation Flow - -### How Services Validate JWTs - -``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Chat Backend│ │ mana-core-auth │ │ jwks table │ -└─────┬───────┘ └────────┬─────────┘ └────────┬────────┘ - │ │ │ - │ POST /api/v1/auth/validate │ - │ { token: "eyJ..." } │ │ - │───────────────────────>│ │ - │ │ │ - │ │ GET /api/v1/auth/jwks │ - │ │─────────────────────────>│ - │ │ │ - │ │<─────────────────────────│ - │ │ { keys: [...] } │ - │ │ │ - │ │ jwtVerify(token, JWKS) │ - │ │ (using jose library) │ - │ │ │ - │<───────────────────────│ │ - │ { valid: true, │ │ - │ payload: {...} } │ │ -``` - -### Implementation - -```typescript -// src/auth/services/better-auth.service.ts -async validateToken(token: string): Promise { - // Use jose library (NOT jsonwebtoken!) - const JWKS = createRemoteJWKSet( - new URL('/api/v1/auth/jwks', 'http://localhost:3001') - ); - - const { payload } = await jwtVerify(token, JWKS, { - issuer: 'manacore', - audience: 'manacore', - }); - - return { valid: true, payload }; -} -``` - ---- - -## Common Mistakes & Fixes - -### ❌ Mistake 1: Using RS256 with jsonwebtoken - -```typescript -// WRONG - Don't do this! -import * as jwt from 'jsonwebtoken'; - -const token = jwt.sign(payload, privateKey, { - algorithm: 'RS256', // Better Auth uses EdDSA! -}); - -jwt.verify(token, publicKey, { - algorithms: ['RS256'], // Will fail for Better Auth tokens -}); -``` - -**Fix:** Use `jose` library with Better Auth's JWKS: - -```typescript -// CORRECT -import { jwtVerify, createRemoteJWKSet } from 'jose'; - -const JWKS = createRemoteJWKSet(new URL('/api/v1/auth/jwks', baseUrl)); -const { payload } = await jwtVerify(token, JWKS, { issuer, audience }); -``` - -### ❌ Mistake 2: Configuring JWT keys in .env - -```env -# WRONG - These are for RS256, Better Auth uses EdDSA -JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----..." -JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..." -``` - -**Fix:** Better Auth auto-generates EdDSA keys and stores them in `auth.jwks` table. No manual key configuration needed for JWT signing. - -### ❌ Mistake 3: Issuer Mismatch - -```typescript -// WRONG - Hardcoded issuer different from config -jwt({ - jwt: { - issuer: 'mana-core', // Signing with this - }, -}); - -// But validating with: -jwtVerify(token, JWKS, { - issuer: 'manacore', // Different! Will fail. -}); -``` - -**Fix:** Use consistent issuer from environment: - -```typescript -issuer: process.env.JWT_ISSUER || 'manacore', -``` - ---- - -## API Endpoints - -### Authentication - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/auth/register` | POST | Register B2C user | -| `/api/v1/auth/login` | POST | Sign in, returns JWT | -| `/api/v1/auth/logout` | POST | Sign out | -| `/api/v1/auth/refresh` | POST | Refresh access token | -| `/api/v1/auth/validate` | POST | Validate JWT token | -| `/api/v1/auth/jwks` | GET | Get JWKS public keys | -| `/api/v1/auth/session` | GET | Get current session | - -### Organizations (B2B) - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/auth/register/b2b` | POST | Register organization | -| `/api/v1/auth/organizations` | GET | List user's orgs | -| `/api/v1/auth/organizations/:id` | GET | Get org details | -| `/api/v1/auth/organizations/:id` | PUT | Update org (name, logo, metadata) | -| `/api/v1/auth/organizations/:id` | DELETE | Delete organization (owner only) | -| `/api/v1/auth/organizations/:id/invite` | POST | Invite employee | -| `/api/v1/auth/organizations/:id/members` | GET | List org members | -| `/api/v1/auth/organizations/:id/members/:memberId` | DELETE | Remove member | -| `/api/v1/auth/organizations/:orgId/members/:memberId/role` | PATCH | Update member role | -| `/api/v1/auth/organizations/:id/invitations` | GET | List org invitations | -| `/api/v1/auth/organizations/set-active` | POST | Switch active org | -| `/api/v1/auth/organizations/accept-invitation` | POST | Accept invitation | - -### Invitations - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/v1/auth/invitations` | GET | List user's pending invitations | -| `/api/v1/auth/invitations/:id` | DELETE | Cancel or reject invitation | - ---- - -## Token Storage (Frontend) - -```typescript -// Storage keys used by @manacore/shared-auth -const STORAGE_KEYS = { - APP_TOKEN: '@auth/appToken', // JWT access token - REFRESH_TOKEN: '@auth/refreshToken', // Session token for refresh - USER_EMAIL: '@auth/userEmail', -}; - -// Reading token for API calls -const token = localStorage.getItem('@auth/appToken'); -``` - ---- - -## Database Schema - -### jwks Table (Better Auth JWT Plugin) - -```sql -CREATE TABLE auth.jwks ( - id TEXT PRIMARY KEY, - public_key TEXT NOT NULL, -- EdDSA public key (JSON) - private_key TEXT NOT NULL, -- EdDSA private key (JSON) - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -Better Auth automatically: -1. Creates keys on first JWT sign -2. Stores them in this table -3. Uses them for all subsequent operations - ---- - -## Debugging - -### Check JWT Algorithm - -```bash -# Decode JWT header (without verification) -echo "eyJhbG..." | cut -d'.' -f1 | base64 -d - -# Should show: { "alg": "EdDSA", "kid": "..." } -# If you see "RS256", something is wrong! -``` - -### Test JWKS Endpoint - -```bash -curl http://localhost:3001/api/v1/auth/jwks -# Should return: { "keys": [{ "crv": "Ed25519", "kty": "OKP", ... }] } -``` - -### Test Token Validation - -```bash -curl -X POST http://localhost:3001/api/v1/auth/validate \ - -H "Content-Type: application/json" \ - -d '{"token": "eyJhbGciOiJFZERTQSIs..."}' - -# Should return: { "valid": true, "payload": {...} } -``` - ---- - -## Related Files - -| File | Purpose | -|------|---------| -| `src/auth/better-auth.config.ts` | Better Auth configuration | -| `src/auth/services/better-auth.service.ts` | Auth service with JWT validation | -| `src/auth/auth.controller.ts` | Auth endpoints including `/jwks` | -| `src/db/schema/auth.schema.ts` | Database schema including `jwks` table | -| `src/config/configuration.ts` | Environment configuration | - ---- - -## Checklist for New Developers - -- [ ] Read Better Auth documentation: https://www.better-auth.com/docs -- [ ] Understand that Better Auth uses **EdDSA**, not RS256 -- [ ] Never use `jsonwebtoken` for Better Auth tokens - use `jose` -- [ ] JWT validation must use JWKS endpoint, not static keys -- [ ] Keep JWT claims minimal - fetch dynamic data via APIs -- [ ] Test with actual Better Auth tokens, not manually created ones diff --git a/services/mana-core-auth/docs/DATABASE_SCHEMA.md b/services/mana-core-auth/docs/DATABASE_SCHEMA.md deleted file mode 100644 index a0ca8c4e3..000000000 --- a/services/mana-core-auth/docs/DATABASE_SCHEMA.md +++ /dev/null @@ -1,295 +0,0 @@ -# Database Schema Documentation - -## Overview - -The Mana Core authentication service uses PostgreSQL with two main schemas: - -- `auth` - User authentication, sessions, and organization management -- `credits` - Credit system for users - -## Schema Diagrams - -### Authentication Schema (auth) - -``` -auth.users (UUID) -├── auth.sessions (user sessions) -├── auth.accounts (OAuth providers + credentials) -├── auth.verifications (email verification, password reset) -├── auth.jwks (EdDSA keys for JWT signing) -├── auth.members (organization membership) ──┐ -└── auth.invitations (org invitations) ───────┤ - │ -auth.organizations (TEXT) ←───────────────────┘ -``` - -### Credits Schema (credits) - -``` -credits.balances (user credit balances) -├── credits.transactions (all credit movements) -├── credits.purchases (credit purchases via Stripe) -├── credits.packages (pricing tiers) -└── credits.gift_codes (gift codes for sharing credits) -``` - -## Core Tables - -### auth.users - -Main user table managed by Better Auth. - -```sql -CREATE TABLE auth.users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT UNIQUE NOT NULL, - name TEXT, - email_verified BOOLEAN DEFAULT false, - image TEXT, - role TEXT DEFAULT 'user', - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### auth.sessions - -Active user sessions. - -```sql -CREATE TABLE auth.sessions ( - id TEXT PRIMARY KEY, - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - token TEXT UNIQUE NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - ip_address TEXT, - user_agent TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### auth.jwks - -EdDSA keys for JWT signing (managed by Better Auth). - -```sql -CREATE TABLE auth.jwks ( - id TEXT PRIMARY KEY, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -## Credit Tables - -### credits.balances - -User credit balances with optimistic locking. - -```sql -CREATE TABLE credits.balances ( - user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - balance INTEGER DEFAULT 0 NOT NULL, - total_earned INTEGER DEFAULT 0 NOT NULL, - total_spent INTEGER DEFAULT 0 NOT NULL, - version INTEGER DEFAULT 0 NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -**Key Design Decisions:** - -- `balance`: Current available credits -- `total_earned`: Lifetime credits received (purchases + gifts) -- `total_spent`: Lifetime credits spent -- `version`: Enables optimistic locking to prevent race conditions - -### credits.transactions - -Immutable ledger of all credit movements. - -```sql -CREATE TABLE credits.transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - type TEXT NOT NULL, -- 'purchase', 'usage', 'refund', 'gift' - status TEXT NOT NULL, -- 'pending', 'completed', 'failed' - amount INTEGER NOT NULL, -- Positive for credits in, negative for out - balance_before INTEGER NOT NULL, - balance_after INTEGER NOT NULL, - app_id TEXT, -- Which app used credits - description TEXT, - idempotency_key TEXT UNIQUE, -- Prevent duplicate transactions - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX transactions_user_id_idx ON credits.transactions(user_id); -CREATE INDEX transactions_created_at_idx ON credits.transactions(created_at); -CREATE INDEX transactions_app_id_idx ON credits.transactions(app_id); -``` - -**Transaction Types:** - -| Type | Description | -|------|-------------| -| `purchase` | Credits bought via Stripe | -| `usage` | Credits spent in an app | -| `refund` | Credits returned (e.g., failed operation) | -| `gift` | Credits received via gift code | - -### credits.packages - -Available credit packages for purchase. - -```sql -CREATE TABLE credits.packages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - credits INTEGER NOT NULL, - price_euro_cents INTEGER NOT NULL, - stripe_price_id TEXT, - active BOOLEAN DEFAULT true, - sort_order INTEGER DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### credits.purchases - -Purchase history linked to Stripe. - -```sql -CREATE TABLE credits.purchases ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - package_id UUID REFERENCES credits.packages(id), - credits INTEGER NOT NULL, - price_euro_cents INTEGER NOT NULL, - stripe_payment_intent_id TEXT, - stripe_checkout_session_id TEXT, - status TEXT NOT NULL, -- 'pending', 'completed', 'failed', 'refunded' - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### credits.gift_codes - -Gift codes for sharing credits. - -```sql -CREATE TABLE credits.gift_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code TEXT UNIQUE NOT NULL, - credits INTEGER NOT NULL, - created_by UUID REFERENCES auth.users(id), - redeemed_by UUID REFERENCES auth.users(id), - target_email TEXT, -- If set, only this email can redeem - expires_at TIMESTAMPTZ, - redeemed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -**Key Features:** - -- `target_email`: Pre-assign gift to specific email (auto-redeems on registration) -- `expires_at`: Optional expiration date -- `redeemed_by` + `redeemed_at`: Track redemption - -## Organization Tables (for Auth only) - -Organizations are used for team management, not credits. - -### auth.organizations - -```sql -CREATE TABLE auth.organizations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT UNIQUE, - logo TEXT, - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -### auth.members - -Links users to organizations with roles. - -```sql -CREATE TABLE auth.members ( - id TEXT PRIMARY KEY, - organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE, - user_id TEXT NOT NULL, - role TEXT NOT NULL, -- 'owner', 'admin', 'member' - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -## Optimistic Locking - -The `credits.balances` table uses a `version` column for optimistic locking: - -```typescript -// Prevent race conditions when using credits -const result = await db - .update(balances) - .set({ - balance: sql`balance - ${amount}`, - totalSpent: sql`total_spent + ${amount}`, - version: sql`version + 1`, - }) - .where( - and( - eq(balances.userId, userId), - eq(balances.version, currentVersion), - gte(balances.balance, amount) - ) - ); - -if (result.rowCount === 0) { - throw new Error('Concurrent modification or insufficient balance'); -} -``` - -## Idempotency - -The `idempotency_key` column in `credits.transactions` prevents duplicate operations: - -```typescript -// Check if transaction already exists -const existing = await db.query.transactions.findFirst({ - where: eq(transactions.idempotencyKey, idempotencyKey) -}); - -if (existing) { - return existing; // Return existing transaction, don't create duplicate -} -``` - -## Schema Files - -All database tables are defined in TypeScript using Drizzle ORM: - -``` -src/db/schema/ -├── auth.schema.ts # Users, sessions, accounts, jwks -├── organizations.schema.ts # Organizations, members, invitations -├── credits.schema.ts # Balances, transactions, packages, gifts -└── index.ts # Export all schemas -``` - -## Commands - -```bash -# Push schema to database (development) -pnpm db:push - -# Open Drizzle Studio to view/edit data -pnpm db:studio -``` diff --git a/services/mana-core-auth/docs/DISASTER_RECOVERY.md b/services/mana-core-auth/docs/DISASTER_RECOVERY.md deleted file mode 100644 index 7c3b1f297..000000000 --- a/services/mana-core-auth/docs/DISASTER_RECOVERY.md +++ /dev/null @@ -1,306 +0,0 @@ -# Mana Core Auth - Disaster Recovery - -## Overview - -This document describes backup, recovery, and disaster recovery procedures for the Mana Core Auth service. - -## Data Assets - -### Critical Data - -| Data | Location | Recovery Priority | -|------|----------|-------------------| -| User accounts | `auth.users` table | Critical | -| Sessions | `auth.sessions` table | High (can regenerate) | -| JWKS keys | `auth.jwks` table | Critical | -| Organizations | `auth.organizations` table | Critical | -| Credit balances | `credits.balances` table | Critical | - -### Non-Critical Data (Can Regenerate) - -- Sessions (users can re-login) -- Verification tokens (users can request new ones) -- Rate limit counters (stored in Redis) - -## Backup Strategy - -### Database Backups - -#### Automated Daily Backups - -```bash -#!/bin/bash -# backup-database.sh - -BACKUP_DIR="/backups/mana-core-auth" -DATE=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="${BACKUP_DIR}/manacore_auth_${DATE}.sql.gz" - -# Create backup -pg_dump "$DATABASE_URL" | gzip > "$BACKUP_FILE" - -# Keep last 30 days -find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete - -# Upload to S3 (optional) -aws s3 cp "$BACKUP_FILE" "s3://your-backup-bucket/mana-core-auth/" -``` - -#### Before Major Changes - -Always create a manual backup before: -- Database migrations -- Schema changes -- Bulk data operations - -```bash -pg_dump "$DATABASE_URL" > pre_migration_backup.sql -``` - -### Redis Backups (if used) - -Redis data is ephemeral (sessions). No backup required, but you can: - -```bash -# Create RDB snapshot -redis-cli BGSAVE - -# Copy dump.rdb to backup location -cp /var/lib/redis/dump.rdb /backups/redis/ -``` - -### JWKS Key Backup - -The JWKS keys are critical for JWT validation. Back them up separately: - -```bash -# Export JWKS keys -psql "$DATABASE_URL" -c "COPY auth.jwks TO '/backups/jwks_backup.csv' CSV HEADER;" -``` - -## Recovery Procedures - -### Scenario 1: Database Corruption - -1. **Stop the service** - ```bash - docker stop mana-core-auth - ``` - -2. **Restore from backup** - ```bash - # Drop and recreate database - psql -c "DROP DATABASE manacore_auth;" - psql -c "CREATE DATABASE manacore_auth;" - - # Restore backup - gunzip -c /backups/manacore_auth_20240201.sql.gz | psql manacore_auth - ``` - -3. **Verify data integrity** - ```bash - psql manacore_auth -c "SELECT COUNT(*) FROM auth.users;" - psql manacore_auth -c "SELECT COUNT(*) FROM auth.jwks;" - ``` - -4. **Restart the service** - ```bash - docker start mana-core-auth - ``` - -5. **Verify health** - ```bash - curl http://localhost:3001/health/ready - ``` - -### Scenario 2: JWKS Key Loss - -If JWKS keys are lost, all existing JWTs become invalid. - -1. **Option A: Restore from backup** - ```bash - psql "$DATABASE_URL" -c "COPY auth.jwks FROM '/backups/jwks_backup.csv' CSV HEADER;" - ``` - -2. **Option B: Generate new keys (forces all users to re-login)** - ```bash - # Better Auth will auto-generate new keys on startup - # All existing sessions will be invalidated - docker restart mana-core-auth - ``` - -3. **Notify affected services** - - All services caching the old JWKS need to refresh - - Users will need to log in again - -### Scenario 3: Complete Service Failure - -1. **Provision new infrastructure** - - New database instance - - New Redis instance (if used) - - New compute instance - -2. **Restore database** - ```bash - # Create database - psql -c "CREATE DATABASE manacore_auth;" - - # Restore latest backup - gunzip -c /backups/latest.sql.gz | psql manacore_auth - ``` - -3. **Update DNS/Load Balancer** - - Point to new service instance - -4. **Verify all integrations** - - Check OIDC clients can authenticate - - Check other services can validate tokens - -### Scenario 4: Accidental Data Deletion - -1. **Identify affected data** - ```sql - -- Check what's missing - SELECT COUNT(*) FROM auth.users WHERE deleted_at IS NOT NULL; - ``` - -2. **Restore from point-in-time backup** - ```bash - # If using PostgreSQL with WAL archiving - pg_restore --target-time="2024-02-01 10:00:00" backup.dump - ``` - -3. **Selective restore** - ```sql - -- Restore specific users from backup database - INSERT INTO auth.users - SELECT * FROM backup_db.auth.users - WHERE id IN ('user1', 'user2'); - ``` - -## Key Rotation - -### Scheduled Key Rotation - -JWKS keys should be rotated periodically (recommended: every 90 days). - -1. **Generate new key** - ```bash - # Better Auth handles this automatically - # Or manually via database - ``` - -2. **Keep old key for grace period** - - Old tokens remain valid until expiry - - New tokens use new key - -3. **Remove old key after grace period** - ```sql - DELETE FROM auth.jwks - WHERE created_at < NOW() - INTERVAL '7 days' - AND id != (SELECT id FROM auth.jwks ORDER BY created_at DESC LIMIT 1); - ``` - -### Emergency Key Rotation - -If keys are compromised: - -1. **Immediately revoke old keys** - ```sql - DELETE FROM auth.jwks; - ``` - -2. **Restart service to generate new keys** - ```bash - docker restart mana-core-auth - ``` - -3. **Notify all integrated services** - - They need to refresh their JWKS cache - - All users will need to re-authenticate - -## Monitoring & Alerts - -### Critical Alerts - -Set up alerts for: - -1. **Backup failures** - - Backup script exit code != 0 - - Backup file size = 0 - -2. **Database health** - - Connection failures - - Replication lag (if applicable) - -3. **Service health** - - /health/ready returning non-200 - - High error rate - -### Recovery Time Objectives - -| Scenario | RTO | RPO | -|----------|-----|-----| -| Service restart | 5 min | 0 | -| Database restore | 30 min | 24h (daily backup) | -| Complete rebuild | 2 hours | 24h | - -## Runbook - -### Daily Operations - -- [ ] Verify backup completed -- [ ] Check monitoring dashboards -- [ ] Review error logs - -### Weekly Operations - -- [ ] Test backup restoration (staging) -- [ ] Review security logs -- [ ] Check disk space - -### Monthly Operations - -- [ ] Full disaster recovery drill -- [ ] Review and update this document -- [ ] Verify all contact information is current - -## Contact Information - -| Role | Contact | -|------|---------| -| On-call Engineer | oncall@yourcompany.com | -| Database Admin | dba@yourcompany.com | -| Security Team | security@yourcompany.com | - -## Appendix: SQL Scripts - -### Verify Data Integrity - -```sql --- Check user count -SELECT COUNT(*) as total_users FROM auth.users; - --- Check for orphaned data -SELECT COUNT(*) as orphaned_sessions -FROM auth.sessions s -LEFT JOIN auth.users u ON s.user_id = u.id -WHERE u.id IS NULL; - --- Check JWKS keys -SELECT id, created_at FROM auth.jwks ORDER BY created_at DESC; - --- Check credit balances -SELECT COUNT(*) as users_with_balance -FROM credits.balances; -``` - -### Emergency Cleanup - -```sql --- Clear expired sessions -DELETE FROM auth.sessions WHERE expires_at < NOW(); - --- Clear expired verification tokens -DELETE FROM auth.verification_tokens WHERE expires_at < NOW(); -``` diff --git a/services/mana-core-auth/docs/GIFT_CODES.md b/services/mana-core-auth/docs/GIFT_CODES.md deleted file mode 100644 index 8e461f9dc..000000000 --- a/services/mana-core-auth/docs/GIFT_CODES.md +++ /dev/null @@ -1,257 +0,0 @@ -# Gift Code System - -User-generated gift codes for sharing credits across the Mana ecosystem. - -## Overview - -Users can create gift codes to share credits with others. The system supports various modes: - -| Type | Description | -|------|-------------| -| `simple` | Single-use, one recipient | -| `personalized` | Restricted to specific email/Matrix ID | -| `split` | Divided into portions (e.g., 100 credits / 5 = 20 each) | -| `first_come` | First N users get full amount | -| `riddle` | Requires correct answer to redeem | - -## API Endpoints - -Base URL: `/api/v1/gifts` - -| Method | Endpoint | Auth | Description | -|--------|----------|------|-------------| -| GET | `/:code` | - | Get gift code info (public preview) | -| POST | `/` | JWT | Create new gift code | -| POST | `/:code/redeem` | JWT | Redeem a gift code | -| GET | `/me/created` | JWT | List codes you created | -| GET | `/me/received` | JWT | List gifts you received | -| DELETE | `/:id` | JWT | Cancel code & refund unclaimed | - -## Usage Examples - -### Create Gift Code - -```bash -curl -X POST "https://auth.mana.how/api/v1/gifts" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "credits": 100, - "message": "Happy Birthday!", - "type": "simple" - }' -``` - -**Response:** -```json -{ - "id": "uuid", - "code": "ABC123", - "url": "https://mana.how/g/ABC123", - "totalCredits": 100, - "creditsPerPortion": 100, - "totalPortions": 1, - "type": "simple", - "expiresAt": "2026-05-14T00:00:00Z" -} -``` - -### Create Split Gift (5 portions) - -```bash -curl -X POST "https://auth.mana.how/api/v1/gifts" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "credits": 100, - "type": "split", - "portions": 5, - "message": "Share this with friends!" - }' -``` - -Each recipient gets 20 credits (100 / 5). - -### Create Riddle Gift - -```bash -curl -X POST "https://auth.mana.how/api/v1/gifts" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "credits": 50, - "type": "riddle", - "riddleQuestion": "What is the capital of Germany?", - "riddleAnswer": "Berlin" - }' -``` - -### Get Gift Info (Public) - -```bash -curl "https://auth.mana.how/api/v1/gifts/ABC123" -``` - -**Response:** -```json -{ - "code": "ABC123", - "type": "simple", - "status": "active", - "creditsPerPortion": 100, - "totalPortions": 1, - "remainingPortions": 1, - "message": "Happy Birthday!", - "hasRiddle": false, - "isPersonalized": false, - "expiresAt": "2026-05-14T00:00:00Z", - "creatorName": "John Doe" -} -``` - -### Redeem Gift Code - -```bash -curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{}' -``` - -For riddle gifts, include the answer: -```bash -curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"answer": "Berlin"}' -``` - -**Response:** -```json -{ - "success": true, - "creditsReceived": 100, - "newBalance": 250, - "message": "Happy Birthday!" -} -``` - -## Matrix Bot Commands - -### German -``` -!geschenk 50 # Simple gift -!geschenk 100 /5 # Split into 5 portions -!geschenk 50 ?="Berlin" # With riddle -!geschenk 50 "Viel Spass!" # With message -!einloesen ABC123 # Redeem code -!einloesen ABC123 Berlin # Redeem with riddle answer -!meine-geschenke # List your gifts -``` - -### English -``` -!gift 50 -!gift 100 /5 -!gift 50 ?="Berlin" -!redeem ABC123 -!my-gifts -``` - -## Database Schema - -### Tables - -**gifts.gift_codes** -- `id` - UUID primary key -- `code` - Unique 6-char code (e.g., "ABC123") -- `short_url` - Full URL (e.g., "mana.how/g/ABC123") -- `creator_id` - FK to auth.users -- `total_credits` - Reserved amount -- `credits_per_portion` - Credits per redemption -- `total_portions` - Number of portions -- `claimed_portions` - Portions already redeemed -- `type` - simple|personalized|split|first_come|riddle -- `status` - active|depleted|expired|cancelled|refunded -- `target_email` - For personalized gifts -- `target_matrix_id` - For personalized gifts -- `riddle_question` - Question text -- `riddle_answer_hash` - bcrypt hash of answer -- `message` - Optional message -- `expires_at` - Expiration timestamp -- `reservation_transaction_id` - FK to credits.transactions - -**gifts.gift_redemptions** -- `id` - UUID primary key -- `gift_code_id` - FK to gift_codes -- `redeemer_user_id` - FK to auth.users -- `status` - success|failed_wrong_answer|failed_wrong_user|... -- `credits_received` - Amount credited -- `portion_number` - Which portion was claimed -- `credit_transaction_id` - FK to credits.transactions -- `source_app_id` - 'matrix-bot', 'web', etc. - -### Transaction Types - -Credits schema includes gift-related transaction types: -- `gift_reserve` - Credits reserved when creating gift -- `gift_release` - Credits returned when cancelling gift -- `gift_receive` - Credits received when redeeming gift - -## Integration Points - -### Web Apps (SvelteKit) -```typescript -// Fetch gift info -const response = await fetch(`/api/v1/gifts/${code}`); -const giftInfo = await response.json(); - -// Redeem -const result = await fetch(`/api/v1/gifts/${code}/redeem`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ answer: riddleAnswer }) -}); -``` - -### Matrix Bots -```typescript -import { GiftService } from '@manacore/bot-services'; -import { handleGiftCommand } from '@manacore/matrix-bot-common'; - -// In bot command handler -if (isGiftCommand(command)) { - return handleGiftCommand(this, roomId, userId, command, args); -} -``` - -### Mobile Apps (Expo) -Same REST API, use fetch or axios with JWT token. - -## Security - -- Gift codes use 6-char alphanumeric codes (no ambiguous chars) -- Riddle answers are bcrypt hashed -- Row-level locking prevents race conditions -- Credits are reserved atomically when creating gifts -- Personalized gifts verify email or Matrix ID - -## Configuration - -Environment variables: -```env -# Base URL for short links -APP_BASE_URL=https://mana.how -``` - -Gift code rules (hardcoded): -```typescript -const GIFT_CODE_RULES = { - minCredits: 1, - maxCredits: 10000, - maxPortions: 100, - maxMessageLength: 500, - maxRiddleQuestionLength: 200, - defaultExpirationDays: 90, -}; -``` diff --git a/services/mana-core-auth/docs/MATRIX_SSO_INTEGRATION.md b/services/mana-core-auth/docs/MATRIX_SSO_INTEGRATION.md deleted file mode 100644 index 0904cfa30..000000000 --- a/services/mana-core-auth/docs/MATRIX_SSO_INTEGRATION.md +++ /dev/null @@ -1,191 +0,0 @@ -# Matrix SSO Integration - -This document describes how Mana Core Auth provides Single Sign-On (SSO) for Matrix/Synapse using OpenID Connect (OIDC). - -## Overview - -Mana Core Auth acts as an **OIDC Provider** (Identity Provider), allowing Matrix Synapse to authenticate users via SSO. Users can sign in to Matrix using their Mana Core credentials. - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Matrix Client │────▶│ Synapse │────▶│ Mana Core Auth │ -│ (Element) │ │ (matrix.mana.how) │ │ (auth.mana.how) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ │ - │ 1. Click "SSO" │ │ - │─────────────────────▶│ │ - │ │ 2. Redirect to │ - │ │ OIDC authorize │ - │ │──────────────────────▶│ - │ │ │ - │ │ 3. Show login page │ - │◀─────────────────────────────────────────────│ - │ │ │ - │ 4. User logs in │ │ - │─────────────────────────────────────────────▶│ - │ │ │ - │ │ 5. Redirect with │ - │ │ auth code │ - │ │◀──────────────────────│ - │ │ │ - │ │ 6. Exchange code │ - │ │ for tokens │ - │ │──────────────────────▶│ - │ │ │ - │ 7. Login complete │◀──────────────────────│ - │◀─────────────────────│ │ -``` - -## OIDC Endpoints - -Mana Core Auth exposes the following OIDC endpoints: - -| Endpoint | URL | Description | -|----------|-----|-------------| -| Discovery | `https://auth.mana.how/.well-known/openid-configuration` | OIDC discovery document | -| Authorize | `https://auth.mana.how/api/auth/oauth2/authorize` | Authorization endpoint | -| Token | `https://auth.mana.how/api/auth/oauth2/token` | Token endpoint | -| UserInfo | `https://auth.mana.how/api/auth/oauth2/userinfo` | User info endpoint | -| JWKS | `https://auth.mana.how/api/auth/jwks` | JSON Web Key Set | -| Login | `https://auth.mana.how/login` | SSO login page | - -## Synapse Configuration - -The Matrix Synapse server is configured with OIDC in `docker/matrix/homeserver.yaml`: - -```yaml -oidc_providers: - - idp_id: manacore - idp_name: "Mana Core" - idp_brand: "org.matrix.custom" - discover: true - issuer: "https://auth.mana.how" - client_id: "matrix-synapse" - client_secret: "" - scopes: ["openid", "profile", "email"] - user_mapping_provider: - config: - subject_claim: "sub" - localpart_template: "{{ user.email.split('@')[0] }}" - display_name_template: "{{ user.name }}" - email_template: "{{ user.email }}" -``` - -## OAuth Application Registration - -The Matrix Synapse client is registered in the auth database: - -```sql -INSERT INTO auth.oauth_applications ( - id, name, client_id, client_secret, redirect_urls, type -) VALUES ( - 'matrix-synapse-client', - 'Matrix Synapse', - 'matrix-synapse', - '', - '["https://matrix.mana.how/_synapse/client/oidc/callback"]', - 'web' -); -``` - -## Authentication Flow - -1. **User initiates SSO**: User clicks "Sign in with Mana Core" on Element/Matrix client -2. **Synapse redirects**: Synapse redirects to Mana Core Auth's authorization endpoint -3. **Login page**: If not logged in, user sees the Mana Core login page -4. **User authenticates**: User enters email and password -5. **Authorization**: After successful login, user is redirected back to authorization endpoint -6. **Token exchange**: Synapse exchanges the authorization code for tokens -7. **User mapping**: Synapse creates/links the Matrix user based on OIDC claims -8. **Login complete**: User is logged into Matrix - -## Claims Provided - -The OIDC tokens include the following claims: - -| Claim | Description | -|-------|-------------| -| `sub` | User ID | -| `email` | User's email address | -| `email_verified` | Whether email is verified | -| `name` | User's display name | - -## Testing the Integration - -### Test OIDC Discovery - -```bash -curl https://auth.mana.how/.well-known/openid-configuration | jq -``` - -### Test Matrix SSO Redirect - -```bash -curl -I "https://matrix.mana.how/_matrix/client/v3/login/sso/redirect/oidc-manacore?redirectUrl=https://element.mana.how" -``` - -### Check Matrix Login Methods - -```bash -curl https://matrix.mana.how/_matrix/client/v3/login | jq '.flows[] | select(.type | contains("sso"))' -``` - -Expected output: -```json -{ - "type": "m.login.sso", - "identity_providers": [ - { - "id": "oidc-manacore", - "name": "Mana Core", - "brand": "org.matrix.custom" - } - ] -} -``` - -## Troubleshooting - -### JWKS Fetch Fails - -If Synapse can't fetch JWKS: -1. Check JWKS endpoint: `curl https://auth.mana.how/api/auth/jwks` -2. Verify Synapse can reach auth service (network/DNS) -3. Check Synapse logs for OIDC errors - -### Login Page Not Found - -If the login page returns 404: -1. Check that `/login` is excluded from global prefix in `main.ts` -2. Verify `OidcLoginController` is registered in `AuthModule` - -### Authorization Fails - -If authorization returns errors: -1. Check client_id matches registered OAuth application -2. Verify redirect_uri matches exactly (including trailing slash) -3. Check that required scopes are requested - -### Token Exchange Fails - -If token exchange fails: -1. Check client_secret is correct -2. Verify token endpoint is accessible -3. Check Synapse logs for detailed error messages - -## Security Considerations - -1. **Client Secret**: The OAuth client secret is stored securely and should never be exposed -2. **HTTPS Only**: All OIDC endpoints use HTTPS -3. **Token Expiry**: ID tokens expire after 15 minutes -4. **PKCE**: Authorization code flow uses PKCE for added security - -## Related Files - -| File | Purpose | -|------|---------| -| `src/auth/better-auth.config.ts` | OIDC Provider plugin configuration | -| `src/auth/oidc.controller.ts` | OIDC endpoint routing | -| `src/auth/oidc-login.controller.ts` | SSO login page | -| `src/db/schema/auth.schema.ts` | OAuth tables (oauth_applications, etc.) | -| `docker/matrix/homeserver.yaml` | Synapse OIDC configuration | diff --git a/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md b/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md deleted file mode 100644 index 58e897d49..000000000 --- a/services/mana-core-auth/docs/PRODUCTION_DEPLOYMENT.md +++ /dev/null @@ -1,299 +0,0 @@ -# Mana Core Auth - Production Deployment Guide - -## Prerequisites - -Before deploying to production, ensure you have: - -1. **PostgreSQL Database** - Version 14+ recommended -2. **Redis** (optional but recommended) - For session storage -3. **SMTP Server** - For email verification and password reset -4. **Stripe Account** - For credit system (optional) -5. **Domain with SSL** - HTTPS is required for secure cookies - -## Environment Variables - -### Required in Production - -```env -NODE_ENV=production -PORT=3001 - -# Database (REQUIRED) -DATABASE_URL=postgresql://user:password@host:5432/manacore_auth - -# Public URL (REQUIRED) -# Used for email verification links, OIDC callbacks -BASE_URL=https://auth.yourdomain.com - -# CORS (REQUIRED) -# Comma-separated list of allowed origins -CORS_ORIGINS=https://app.yourdomain.com,https://admin.yourdomain.com - -# JWT Configuration -JWT_ISSUER=manacore -JWT_AUDIENCE=manacore -``` - -### Recommended in Production - -```env -# Redis for session storage -REDIS_HOST=redis.yourdomain.com -REDIS_PORT=6379 -REDIS_PASSWORD=your-redis-password - -# SMTP for emails -SMTP_HOST=smtp.brevo.com -SMTP_PORT=587 -SMTP_USER=your-smtp-user -SMTP_PASSWORD=your-smtp-password -SMTP_FROM=ManaCore - -# Stripe for credits -STRIPE_SECRET_KEY=sk_live_... -STRIPE_PUBLISHABLE_KEY=pk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# Error tracking -SENTRY_DSN=https://...@sentry.io/... - -# Logging -LOG_LEVEL=info -``` - -## Deployment Options - -### Option 1: Docker (Recommended) - -```bash -# Build the image -docker build -t mana-core-auth:latest -f services/mana-core-auth/Dockerfile . - -# Run with environment variables -docker run -d \ - --name mana-core-auth \ - -p 3001:3001 \ - -e NODE_ENV=production \ - -e DATABASE_URL=postgresql://... \ - -e BASE_URL=https://auth.yourdomain.com \ - -e CORS_ORIGINS=https://app.yourdomain.com \ - -e REDIS_HOST=redis \ - mana-core-auth:latest -``` - -### Option 2: Docker Compose - -```yaml -version: '3.8' - -services: - auth: - build: - context: . - dockerfile: services/mana-core-auth/Dockerfile - ports: - - "3001:3001" - environment: - NODE_ENV: production - DATABASE_URL: postgresql://manacore:${DB_PASSWORD}@db:5432/manacore_auth - BASE_URL: https://auth.yourdomain.com - CORS_ORIGINS: https://app.yourdomain.com - REDIS_HOST: redis - REDIS_PORT: 6379 - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health/ready', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - - db: - image: postgres:16-alpine - environment: - POSTGRES_USER: manacore - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: manacore_auth - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U manacore"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - command: redis-server --requirepass ${REDIS_PASSWORD} - volumes: - - redis_data:/data - -volumes: - postgres_data: - redis_data: -``` - -### Option 3: Kubernetes - -See `k8s/` directory for Kubernetes manifests (if available). - -## Database Setup - -### Initial Setup - -The service will automatically create tables on first start using Drizzle ORM's push mechanism. - -```bash -# For manual schema push (development) -pnpm db:push - -# For production migrations -pnpm db:migrate -``` - -### Migration Strategy - -1. **Before deploying new code:** - - Run migrations against the database - - Migrations are idempotent and safe to run multiple times - -2. **Rolling deployments:** - - Ensure migrations are backwards-compatible - - Deploy migration first, then new code - - Use advisory locks to prevent concurrent migrations - -```bash -# Run migrations manually -DATABASE_URL=postgresql://... pnpm db:migrate -``` - -### Rollback Strategy - -1. **Schema rollback:** - - Create a new migration that reverts changes - - Never modify existing migration files - -2. **Data rollback:** - - Take database backups before major changes - - Use point-in-time recovery if available - -## Health Checks - -The service exposes three health check endpoints: - -| Endpoint | Purpose | Use Case | -|----------|---------|----------| -| `/health` | Basic health | Load balancer health check | -| `/health/live` | Liveness probe | Kubernetes liveness probe | -| `/health/ready` | Readiness probe | Kubernetes readiness probe | - -### Kubernetes Probes - -```yaml -livenessProbe: - httpGet: - path: /health/live - port: 3001 - initialDelaySeconds: 30 - periodSeconds: 10 - -readinessProbe: - httpGet: - path: /health/ready - port: 3001 - initialDelaySeconds: 10 - periodSeconds: 5 - failureThreshold: 3 -``` - -## Monitoring - -### Prometheus Metrics - -Metrics are exposed at `/metrics`: - -- `http_requests_total` - Total HTTP requests -- `http_request_duration_seconds` - Request duration histogram - -### Grafana Dashboard - -Import the dashboard from `monitoring/grafana/dashboards/mana-core-auth.json`. - -### Alerting - -Recommended alerts: - -1. **High error rate**: >5% 5xx responses -2. **Slow response time**: p99 > 2s -3. **Database connection failures**: health check failures -4. **Rate limiting triggered**: high 429 responses - -## Security Checklist - -Before going live: - -- [ ] HTTPS is configured (required for secure cookies) -- [ ] CORS_ORIGINS only includes trusted domains -- [ ] Database password is strong and not in code -- [ ] Redis password is set -- [ ] SMTP credentials are production credentials -- [ ] Stripe keys are live (not test) keys -- [ ] LOG_LEVEL is set to 'info' or 'warn' (not 'debug') -- [ ] Rate limiting is enabled -- [ ] Health checks are configured in load balancer - -## Troubleshooting - -### Service won't start - -1. Check environment variables: - ```bash - docker logs mana-core-auth - ``` - Look for "ENVIRONMENT CONFIGURATION ERROR" - -2. Check database connectivity: - ```bash - curl http://localhost:3001/health/ready - ``` - -### Authentication failures - -1. Check JWKS endpoint: - ```bash - curl http://localhost:3001/api/v1/auth/jwks - ``` - -2. Verify JWT issuer/audience match between services - -### Email not sending - -1. Check SMTP configuration -2. Look for email logs (emails are logged in development) -3. Verify sender domain is authorized - -## Scaling - -### Horizontal Scaling - -The service is stateless and can be horizontally scaled: - -1. Use Redis for session storage (required for multi-instance) -2. Use a load balancer with sticky sessions (optional) -3. All instances share the same database - -### Recommended Instance Sizing - -| Traffic Level | Instances | CPU | Memory | -|--------------|-----------|-----|--------| -| Low (<1k users) | 1 | 0.5 | 512MB | -| Medium (1k-10k) | 2 | 1 | 1GB | -| High (10k-100k) | 3-5 | 2 | 2GB | - -## Backup & Recovery - -See [DISASTER_RECOVERY.md](./DISASTER_RECOVERY.md) for backup and recovery procedures. diff --git a/services/mana-core-auth/drizzle.config.ts b/services/mana-core-auth/drizzle.config.ts deleted file mode 100644 index 35d180045..000000000 --- a/services/mana-core-auth/drizzle.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ - dbName: 'manacore', - schemaFilter: ['auth', 'credits', 'gifts', 'subscriptions', 'public'], -}); diff --git a/services/mana-core-auth/eslint.config.mjs b/services/mana-core-auth/eslint.config.mjs deleted file mode 100644 index 41ef245c0..000000000 --- a/services/mana-core-auth/eslint.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-check -import { - baseConfig, - typescriptConfig, - nestjsConfig, - prettierConfig, -} from '@manacore/eslint-config'; - -export default [ - { - ignores: ['dist/**', 'node_modules/**'], - }, - ...baseConfig, - ...typescriptConfig, - ...nestjsConfig, - ...prettierConfig, -]; diff --git a/services/mana-core-auth/jest.config.js b/services/mana-core-auth/jest.config.js deleted file mode 100644 index 741d949bb..000000000 --- a/services/mana-core-auth/jest.config.js +++ /dev/null @@ -1,62 +0,0 @@ -module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: [ - '**/*.(t|j)s', - '!**/*.module.ts', - '!**/*.interface.ts', - '!**/main.ts', - '!**/*.dto.ts', - '!**/*.schema.ts', - '!**/index.ts', - '!**/migrate.ts', - '!**/connection.ts', - ], - coverageDirectory: '../coverage', - testEnvironment: 'node', - // Handle ESM modules (nanoid, better-auth) - transformIgnorePatterns: ['node_modules/(?!(nanoid|better-auth)/)'], - moduleNameMapper: { - '^src/(.*)$': '/$1', - '^nanoid$': '/../test/__mocks__/nanoid.ts', - '^jose$': '/../test/__mocks__/jose.ts', - '^better-auth$': '/../test/__mocks__/better-auth.ts', - '^better-auth/types$': '/../test/__mocks__/better-auth.ts', - '^better-auth/plugins$': '/../test/__mocks__/better-auth-plugins.ts', - '^better-auth/plugins/(.*)$': '/../test/__mocks__/better-auth-plugins.ts', - '^better-auth/adapters/(.*)$': '/../test/__mocks__/better-auth-adapters.ts', - }, - coverageThreshold: { - global: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, - }, - // Critical paths require 100% coverage - './auth/auth.service.ts': { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - './credits/credits.service.ts': { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - './common/guards/jwt-auth.guard.ts': { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - }, - setupFilesAfterEnv: ['/../test/setup.ts'], - testTimeout: 10000, -}; diff --git a/services/mana-core-auth/nest-cli.json b/services/mana-core-auth/nest-cli.json deleted file mode 100644 index 02776ae81..000000000 --- a/services/mana-core-auth/nest-cli.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true, - "webpack": false, - "tsConfigPath": "tsconfig.json" - } -} diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json deleted file mode 100644 index 77042cfda..000000000 --- a/services/mana-core-auth/package.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "name": "mana-core-auth", - "version": "1.0.0", - "private": true, - "description": "Mana Core Authentication and Credit System", - "main": "dist/main.js", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:e2e": "jest --config ./test/jest-e2e.json", - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx src/db/migrate.ts", - "db:studio": "drizzle-kit studio", - "db:seed:dev": "tsx src/db/seed-dev-user.ts", - "db:seed:oidc": "tsx src/db/seeds/seed-oidc-clients.ts", - "db:seed:plans": "tsx src/db/seeds/seed-subscription-plans.ts" - }, - "dependencies": { - "@google/generative-ai": "^0.24.1", - "@manacore/shared-llm": "workspace:^", - "@manacore/shared-storage": "workspace:*", - "@nestjs/axios": "^4.0.1", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "@nestjs/schedule": "^4.1.2", - "@nestjs/swagger": "^8.1.0", - "@nestjs/throttler": "^6.2.1", - "@simplewebauthn/server": "^13.3.0", - "@types/multer": "^2.0.0", - "axios": "^1.7.2", - "bcryptjs": "^2.4.3", - "better-auth": "^1.4.3", - "body-parser": "^2.2.2", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "cookie-parser": "^1.4.7", - "dotenv": "^16.4.7", - "drizzle-kit": "^0.30.2", - "drizzle-orm": "^0.38.3", - "duckdb-async": "^1.1.1", - "helmet": "^8.0.0", - "jose": "^6.1.2", - "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1", - "nanoid": "^5.0.9", - "nodemailer": "^7.0.12", - "postgres": "^3.4.5", - "prom-client": "^15.1.0", - "redis": "^4.7.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "stripe": "^17.5.0", - "winston": "^3.17.0", - "zod": "^3.24.1" - }, - "devDependencies": { - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^10.4.15", - "@types/bcryptjs": "^2.4.6", - "@types/body-parser": "^1.19.6", - "@types/cookie-parser": "^1.4.7", - "@types/express": "^5.0.0", - "@types/jest": "^29.5.14", - "@types/jsonwebtoken": "^9.0.9", - "@types/node": "^22.10.2", - "@types/nodemailer": "^7.0.5", - "@types/supertest": "^6.0.2", - "@typescript-eslint/eslint-plugin": "^8.18.2", - "@typescript-eslint/parser": "^8.18.2", - "eslint": "^9.17.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.2.1", - "jest": "^29.7.0", - "prettier": "^3.4.2", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.1", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - }, - "engines": { - "node": ">=20.0.0", - "pnpm": ">=9.0.0" - } -} diff --git a/services/mana-core-auth/pnpm-lock.yaml b/services/mana-core-auth/pnpm-lock.yaml deleted file mode 100644 index f5998f423..000000000 --- a/services/mana-core-auth/pnpm-lock.yaml +++ /dev/null @@ -1,7948 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@nestjs/common': - specifier: ^10.4.15 - version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/config': - specifier: ^3.3.0 - version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) - '@nestjs/core': - specifier: ^10.4.15 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^10.4.15 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) - '@nestjs/throttler': - specifier: ^6.2.1 - version: 6.4.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2) - bcrypt: - specifier: ^5.1.1 - version: 5.1.1 - better-auth: - specifier: ^1.1.1 - version: 1.4.1 - class-transformer: - specifier: ^0.5.1 - version: 0.5.1 - class-validator: - specifier: ^0.14.1 - version: 0.14.3 - cookie-parser: - specifier: ^1.4.7 - version: 1.4.7 - drizzle-kit: - specifier: ^0.30.2 - version: 0.30.6 - drizzle-orm: - specifier: ^0.38.3 - version: 0.38.4(kysely@0.28.8)(postgres@3.4.7) - helmet: - specifier: ^8.0.0 - version: 8.1.0 - jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.2 - nanoid: - specifier: ^5.0.9 - version: 5.1.6 - postgres: - specifier: ^3.4.5 - version: 3.4.7 - redis: - specifier: ^4.7.0 - version: 4.7.1 - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - stripe: - specifier: ^17.5.0 - version: 17.7.0 - winston: - specifier: ^3.17.0 - version: 3.18.3 - zod: - specifier: ^3.24.1 - version: 3.25.76 - devDependencies: - '@nestjs/cli': - specifier: ^11.0.0 - version: 11.0.12(@types/node@22.19.1)(esbuild@0.19.12) - '@nestjs/schematics': - specifier: ^11.0.0 - version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) - '@nestjs/testing': - specifier: ^10.4.15 - version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20) - '@types/bcrypt': - specifier: ^5.0.2 - version: 5.0.2 - '@types/cookie-parser': - specifier: ^1.4.7 - version: 1.4.10(@types/express@5.0.5) - '@types/express': - specifier: ^5.0.0 - version: 5.0.5 - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 - '@types/jsonwebtoken': - specifier: ^9.0.7 - version: 9.0.10 - '@types/node': - specifier: ^22.10.2 - version: 22.19.1 - '@types/supertest': - specifier: ^6.0.2 - version: 6.0.3 - '@typescript-eslint/eslint-plugin': - specifier: ^8.18.2 - version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.18.2 - version: 8.48.0(eslint@9.39.1)(typescript@5.9.3) - eslint: - specifier: ^9.17.0 - version: 9.39.1 - eslint-config-prettier: - specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1) - eslint-plugin-prettier: - specifier: ^5.2.1 - version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2) - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - prettier: - specifier: ^3.4.2 - version: 3.6.2 - supertest: - specifier: ^7.0.0 - version: 7.1.4 - ts-jest: - specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) - ts-loader: - specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - tsx: - specifier: ^4.19.2 - version: 4.20.6 - typescript: - specifier: ^5.7.2 - version: 5.9.3 - -packages: - - '@angular-devkit/core@19.2.17': - resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - peerDependencies: - chokidar: ^4.0.0 - peerDependenciesMeta: - chokidar: - optional: true - - '@angular-devkit/core@19.2.19': - resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - peerDependencies: - chokidar: ^4.0.0 - peerDependenciesMeta: - chokidar: - optional: true - - '@angular-devkit/schematics-cli@19.2.19': - resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - hasBin: true - - '@angular-devkit/schematics@19.2.17': - resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - - '@angular-devkit/schematics@19.2.19': - resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} - engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.27.1': - resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.27.1': - resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.27.1': - resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - - '@better-auth/core@1.4.1': - resolution: {integrity: sha512-N4kyRdA472WGLoCjsJpUeYdZZvpoBDgP65hUeQQxTQYwBTqD9O17Tokax9CdNbkb4g34sTfxaJCfcncE3Hy4SA==} - peerDependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - better-call: 1.1.0 - jose: ^6.1.0 - kysely: ^0.28.5 - nanostores: ^1.0.1 - - '@better-auth/telemetry@1.4.1': - resolution: {integrity: sha512-yNeazXYvMbyuCe1AA6tYWsJEKgcS7gF9PmmACmrPVhVBe1ncDhVfWMZ++YCmA2h8hjkR9755ZyofiYRPbj+kXQ==} - peerDependencies: - '@better-auth/core': 1.4.1 - - '@better-auth/utils@0.3.0': - resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} - - '@better-fetch/fetch@1.1.18': - resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} - - '@borewit/text-codec@0.1.1': - resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} - - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - - '@colors/colors@1.6.0': - resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} - engines: {node: '>=0.1.90'} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@dabh/diagnostics@2.0.8': - resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - - '@drizzle-team/brocli@0.10.2': - resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - - '@esbuild-kit/core-utils@3.3.2': - resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild-kit/esm-loader@2.6.5': - resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild/aix-ppc64@0.19.12': - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.18.20': - resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.19.12': - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.18.20': - resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.19.12': - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.18.20': - resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.19.12': - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.18.20': - resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.19.12': - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.18.20': - resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.19.12': - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.18.20': - resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.19.12': - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.18.20': - resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.19.12': - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.18.20': - resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.19.12': - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.18.20': - resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.19.12': - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.18.20': - resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.19.12': - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.18.20': - resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.19.12': - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.18.20': - resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.19.12': - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.18.20': - resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.19.12': - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.18.20': - resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.19.12': - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.18.20': - resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.19.12': - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.18.20': - resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.19.12': - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.18.20': - resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.19.12': - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.18.20': - resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.19.12': - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.18.20': - resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.19.12': - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.18.20': - resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.19.12': - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.18.20': - resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.19.12': - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.18.20': - resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.19.12': - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.1': - resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} - - '@inquirer/checkbox@4.3.2': - resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@4.2.23': - resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.23': - resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@1.0.3': - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/input@4.3.1': - resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.23': - resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.23': - resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.10.1': - resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.3.2': - resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.1.11': - resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.2.2': - resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@4.4.2': - resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} - - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@lukeed/csprng@1.1.0': - resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} - engines: {node: '>=8'} - - '@mapbox/node-pre-gyp@1.0.11': - resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} - hasBin: true - - '@nestjs/cli@11.0.12': - resolution: {integrity: sha512-V3fD1xESlFcJ1xpwOtUhn0edLvIa76Sx8mkvdR1s8cM4c/rZO+yGmXP30ZQwPfIJPTgBvsw93F/i+87eV96wcQ==} - engines: {node: '>= 20.11'} - hasBin: true - peerDependencies: - '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 - '@swc/core': ^1.3.62 - peerDependenciesMeta: - '@swc/cli': - optional: true - '@swc/core': - optional: true - - '@nestjs/common@10.4.20': - resolution: {integrity: sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==} - peerDependencies: - class-transformer: '*' - class-validator: '*' - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - - '@nestjs/config@3.3.0': - resolution: {integrity: sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==} - peerDependencies: - '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 - rxjs: ^7.1.0 - - '@nestjs/core@10.4.20': - resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==} - peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/microservices': ^10.0.0 - '@nestjs/platform-express': ^10.0.0 - '@nestjs/websockets': ^10.0.0 - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - '@nestjs/websockets': - optional: true - - '@nestjs/platform-express@10.4.20': - resolution: {integrity: sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==} - peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/core': ^10.0.0 - - '@nestjs/schematics@11.0.9': - resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} - peerDependencies: - typescript: '>=4.8.2' - - '@nestjs/testing@10.4.20': - resolution: {integrity: sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw==} - peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/core': ^10.0.0 - '@nestjs/microservices': ^10.0.0 - '@nestjs/platform-express': ^10.0.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - - '@nestjs/throttler@6.4.0': - resolution: {integrity: sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA==} - peerDependencies: - '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - reflect-metadata: ^0.1.13 || ^0.2.0 - - '@noble/ciphers@2.0.1': - resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} - engines: {node: '>= 20.19.0'} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@2.0.1': - resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} - engines: {node: '>= 20.19.0'} - - '@nuxtjs/opencollective@0.3.2': - resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} - hasBin: true - - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - - '@petamoriken/float16@3.9.3': - resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - - '@redis/bloom@1.2.0': - resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/client@1.6.1': - resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} - engines: {node: '>=14'} - - '@redis/graph@1.1.1': - resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/json@1.0.7': - resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/search@1.2.0': - resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/time-series@1.1.0': - resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - - '@so-ric/colorspace@1.1.6': - resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} - - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - - '@tokenizer/inflate@0.2.7': - resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} - engines: {node: '>=18'} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/bcrypt@5.0.2': - resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} - - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - - '@types/cookie-parser@1.4.10': - resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} - peerDependencies: - '@types/express': '*' - - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} - - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/express-serve-static-core@5.1.0': - resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} - - '@types/express@5.0.5': - resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} - - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@22.19.1': - resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} - - '@types/qs@6.14.0': - resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} - - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - - '@types/triple-beam@1.3.5': - resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - - '@types/validator@13.15.10': - resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - - '@typescript-eslint/eslint-plugin@8.48.0': - resolution: {integrity: sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.48.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.48.0': - resolution: {integrity: sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.48.0': - resolution: {integrity: sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.48.0': - resolution: {integrity: sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.48.0': - resolution: {integrity: sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.48.0': - resolution: {integrity: sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.48.0': - resolution: {integrity: sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.48.0': - resolution: {integrity: sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.48.0': - resolution: {integrity: sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.48.0': - resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - - abbrev@1.1.1: - resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} - peerDependencies: - ajv: ^8.8.2 - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} - engines: {node: '>=14'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - - aproba@2.1.0: - resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} - - are-we-there-yet@2.0.0: - resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} - engines: {node: '>=10'} - deprecated: This package is no longer supported. - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - array-timsort@1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - baseline-browser-mapping@2.8.31: - resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} - hasBin: true - - bcrypt@5.1.1: - resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} - engines: {node: '>= 10.0.0'} - - better-auth@1.4.1: - resolution: {integrity: sha512-HDVE69Nw6Y1FPTcmFEmPolfsjMfVB5U823Ij9yWBoM8MdHZ2lA3JVus4xQJ2oRE1riJTlcSLFcgJKWGD7V7hmw==} - peerDependencies: - '@lynx-js/react': '*' - '@sveltejs/kit': '*' - next: '*' - react: '*' - react-dom: '*' - solid-js: '*' - svelte: '*' - vue: '*' - peerDependenciesMeta: - '@lynx-js/react': - optional: true - '@sveltejs/kit': - optional: true - next: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vue: - optional: true - - better-call@1.1.0: - resolution: {integrity: sha512-7CecYG+yN8J1uBJni/Mpjryp8bW/YySYsrGEWgFe048ORASjq17keGjbKI2kHEOSc6u8pi11UxzkJ7jIovQw6w==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.0: - resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001757: - resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - class-transformer@0.5.1: - resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} - - class-validator@0.14.3: - resolution: {integrity: sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==} - - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-convert@3.1.3: - resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} - engines: {node: '>=14.6'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - color-name@2.1.0: - resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} - engines: {node: '>=12.20'} - - color-string@2.1.4: - resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} - engines: {node: '>=18'} - - color-support@1.1.3: - resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} - hasBin: true - - color@5.0.3: - resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} - engines: {node: '>=18'} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - - comment-json@4.4.1: - resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} - engines: {node: '>= 6'} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - - consola@2.15.3: - resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} - - console-control-strings@1.1.0: - resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} - - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-parser@1.4.7: - resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} - engines: {node: '>= 0.8.0'} - - cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} - - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} - - cosmiconfig@8.3.6: - resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.0: - resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - delegates@1.0.0: - resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - - dotenv-expand@10.0.0: - resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} - engines: {node: '>=12'} - - dotenv@16.4.5: - resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} - engines: {node: '>=12'} - - drizzle-kit@0.30.6: - resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} - hasBin: true - - drizzle-orm@0.38.4: - resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=4' - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1' - '@prisma/client': '*' - '@tidbcloud/serverless': '*' - '@types/better-sqlite3': '*' - '@types/pg': '*' - '@types/react': '>=18' - '@types/sql.js': '*' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - better-sqlite3: '>=7' - bun-types: '*' - expo-sqlite: '>=14.0.0' - knex: '*' - kysely: '*' - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - prisma: '*' - react: '>=18' - sql.js: '>=1' - sqlite3: '>=5' - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@prisma/client': - optional: true - '@tidbcloud/serverless': - optional: true - '@types/better-sqlite3': - optional: true - '@types/pg': - optional: true - '@types/react': - optional: true - '@types/sql.js': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - knex: - optional: true - kysely: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - prisma: - optional: true - react: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.260: - resolution: {integrity: sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==} - - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - enabled@2.0.0: - resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} - - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} - engines: {node: '>=10.13.0'} - - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild-register@3.6.0: - resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} - peerDependencies: - esbuild: '>=0.12 <1' - - esbuild@0.18.20: - resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-prettier@9.1.2: - resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-prettier@5.5.4: - resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.39.1: - resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} - - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fecha@4.2.3: - resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} - - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - file-type@20.4.1: - resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} - engines: {node: '>=18'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - fn.name@1.1.0: - resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - fork-ts-checker-webpack-plugin@9.1.0: - resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} - engines: {node: '>=14.21.3'} - peerDependencies: - typescript: '>3.6.0' - webpack: ^5.11.0 - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - formidable@3.5.4: - resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} - engines: {node: '>=14.0.0'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - - fs-monkey@1.1.0: - resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gauge@3.0.2: - resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} - engines: {node: '>=10'} - deprecated: This package is no longer supported. - - gel@2.2.0: - resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} - engines: {node: '>= 18.0.0'} - hasBin: true - - generic-pool@3.9.0: - resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} - engines: {node: '>= 4'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - - glob@12.0.0: - resolution: {integrity: sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==} - engines: {node: 20 || >=22} - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - has-unicode@2.0.1: - resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - helmet@8.1.0: - resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} - engines: {node: '>=18.0.0'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.7.0: - resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - iterare@1.2.1: - resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} - engines: {node: '>=6'} - - jackspeak@4.1.1: - resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} - engines: {node: 20 || >=22} - - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jose@6.1.2: - resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} - engines: {node: '>=12', npm: '>=6'} - - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} - - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - kuler@2.0.0: - resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} - - kysely@0.28.8: - resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} - engines: {node: '>=20.0.0'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - libphonenumber-js@1.12.29: - resolution: {integrity: sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} - engines: {node: '>=6.11.5'} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - - logform@2.7.0: - resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} - engines: {node: '>= 12.0.0'} - - lru-cache@11.2.2: - resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} - engines: {node: 20 || >=22} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - - make-dir@3.1.0: - resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} - engines: {node: '>=8'} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - memfs@3.5.3: - resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} - engines: {node: '>= 4.0.0'} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} - - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} - - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} - engines: {node: ^18 || >=20} - hasBin: true - - nanostores@1.1.0: - resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} - engines: {node: ^20.0.0 || >=22.0.0} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - - node-addon-api@5.1.0: - resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} - - node-emoji@1.11.0: - resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - nopt@5.0.0: - resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} - engines: {node: '>=6'} - hasBin: true - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - npmlog@5.0.1: - resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} - deprecated: This package is no longer supported. - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - one-time@1.0.0: - resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} - - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - - path-to-regexp@3.3.0: - resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} - engines: {node: '>=12'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - - postgres@3.4.7: - resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} - engines: {node: '>=12'} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} - engines: {node: '>=6.0.0'} - - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} - hasBin: true - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - redis@4.7.1: - resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} - - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rou3@0.5.1: - resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} - - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-stable-stringify@2.5.0: - resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} - engines: {node: '>=10'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} - - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} - - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} - - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stack-trace@0.0.10: - resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - stripe@17.7.0: - resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} - engines: {node: '>=12.*'} - - strtok3@10.3.4: - resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} - engines: {node: '>=18'} - - superagent@10.2.3: - resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} - engines: {node: '>=14.18.0'} - - supertest@7.1.4: - resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} - engines: {node: '>=14.18.0'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - - synckit@0.11.11: - resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} - engines: {node: ^14.18.0 || >=16.0.0} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - - terser-webpack-plugin@5.3.14: - resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - - terser@5.44.1: - resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} - engines: {node: '>=10'} - hasBin: true - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} - - text-hex@1.0.0: - resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - token-types@6.1.1: - resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} - engines: {node: '>=14.16'} - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - triple-beam@1.4.1: - resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} - engines: {node: '>= 14.0.0'} - - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - ts-jest@29.4.5: - resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - - ts-loader@9.5.4: - resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==} - engines: {node: '>=12.0.0'} - peerDependencies: - typescript: '*' - webpack: ^5.0.0 - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - - tsconfig-paths-webpack-plugin@4.2.0: - resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} - engines: {node: '>=10.13.0'} - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsx@4.20.6: - resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} - engines: {node: '>=18.0.0'} - hasBin: true - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - - uid@2.0.2: - resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} - engines: {node: '>=8'} - - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - - validator@13.15.23: - resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} - engines: {node: '>= 0.10'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - - watchpack@2.4.4: - resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} - engines: {node: '>=10.13.0'} - - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - webpack-node-externals@3.0.0: - resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} - engines: {node: '>=6'} - - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} - engines: {node: '>=10.13.0'} - - webpack@5.100.2: - resolution: {integrity: sha512-QaNKAvGCDRh3wW1dsDjeMdDXwZm2vqq3zn6Pvq4rHOEOGSaUMgOOjG2Y9ZbIGzpfkJk9ZYTHpDqgDfeBDcnLaw==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - - wide-align@1.1.5: - resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} - - winston-transport@4.9.0: - resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} - engines: {node: '>= 12.0.0'} - - winston@3.18.3: - resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} - engines: {node: '>= 12.0.0'} - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} - -snapshots: - - '@angular-devkit/core@19.2.17(chokidar@4.0.3)': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - jsonc-parser: 3.3.1 - picomatch: 4.0.2 - rxjs: 7.8.1 - source-map: 0.7.4 - optionalDependencies: - chokidar: 4.0.3 - - '@angular-devkit/core@19.2.19(chokidar@4.0.3)': - dependencies: - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - jsonc-parser: 3.3.1 - picomatch: 4.0.2 - rxjs: 7.8.1 - source-map: 0.7.4 - optionalDependencies: - chokidar: 4.0.3 - - '@angular-devkit/schematics-cli@19.2.19(@types/node@22.19.1)(chokidar@4.0.3)': - dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@inquirer/prompts': 7.3.2(@types/node@22.19.1) - ansi-colors: 4.1.3 - symbol-observable: 4.0.0 - yargs-parser: 21.1.1 - transitivePeerDependencies: - - '@types/node' - - chokidar - - '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': - dependencies: - '@angular-devkit/core': 19.2.17(chokidar@4.0.3) - jsonc-parser: 3.3.1 - magic-string: 0.30.17 - ora: 5.4.1 - rxjs: 7.8.1 - transitivePeerDependencies: - - chokidar - - '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': - dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - jsonc-parser: 3.3.1 - magic-string: 0.30.17 - ora: 5.4.1 - rxjs: 7.8.1 - transitivePeerDependencies: - - chokidar - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.28.5': {} - - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.0 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@0.2.3': {} - - '@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@standard-schema/spec': 1.0.0 - better-call: 1.1.0 - jose: 6.1.2 - kysely: 0.28.8 - nanostores: 1.1.0 - zod: 4.1.13 - - '@better-auth/telemetry@1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0))': - dependencies: - '@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - - '@better-auth/utils@0.3.0': {} - - '@better-fetch/fetch@1.1.18': {} - - '@borewit/text-codec@0.1.1': {} - - '@colors/colors@1.5.0': - optional: true - - '@colors/colors@1.6.0': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@dabh/diagnostics@2.0.8': - dependencies: - '@so-ric/colorspace': 1.1.6 - enabled: 2.0.0 - kuler: 2.0.0 - - '@drizzle-team/brocli@0.10.2': {} - - '@esbuild-kit/core-utils@3.3.2': - dependencies: - esbuild: 0.18.20 - source-map-support: 0.5.21 - - '@esbuild-kit/esm-loader@2.6.5': - dependencies: - '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.0 - - '@esbuild/aix-ppc64@0.19.12': - optional: true - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.18.20': - optional: true - - '@esbuild/android-arm64@0.19.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.18.20': - optional: true - - '@esbuild/android-arm@0.19.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.18.20': - optional: true - - '@esbuild/android-x64@0.19.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.18.20': - optional: true - - '@esbuild/darwin-arm64@0.19.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.18.20': - optional: true - - '@esbuild/darwin-x64@0.19.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.18.20': - optional: true - - '@esbuild/freebsd-arm64@0.19.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.18.20': - optional: true - - '@esbuild/freebsd-x64@0.19.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.18.20': - optional: true - - '@esbuild/linux-arm64@0.19.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.18.20': - optional: true - - '@esbuild/linux-arm@0.19.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.18.20': - optional: true - - '@esbuild/linux-ia32@0.19.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.18.20': - optional: true - - '@esbuild/linux-loong64@0.19.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.18.20': - optional: true - - '@esbuild/linux-mips64el@0.19.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.18.20': - optional: true - - '@esbuild/linux-ppc64@0.19.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.18.20': - optional: true - - '@esbuild/linux-riscv64@0.19.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.18.20': - optional: true - - '@esbuild/linux-s390x@0.19.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.18.20': - optional: true - - '@esbuild/linux-x64@0.19.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.18.20': - optional: true - - '@esbuild/netbsd-x64@0.19.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.18.20': - optional: true - - '@esbuild/openbsd-x64@0.19.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.18.20': - optional: true - - '@esbuild/sunos-x64@0.19.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.18.20': - optional: true - - '@esbuild/win32-arm64@0.19.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.18.20': - optional: true - - '@esbuild/win32-ia32@0.19.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.18.20': - optional: true - - '@esbuild/win32-x64@0.19.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)': - dependencies: - eslint: 9.39.1 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.1': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.1': - dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.1': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@inquirer/ansi@1.0.2': {} - - '@inquirer/checkbox@4.3.2(@types/node@22.19.1)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/confirm@5.1.21(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/core@10.3.2(@types/node@22.19.1)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/editor@4.2.23(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/external-editor': 1.0.3(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/expand@4.0.23(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/external-editor@1.0.3(@types/node@22.19.1)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.0 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/figures@1.0.15': {} - - '@inquirer/input@4.3.1(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/number@3.0.23(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/password@4.0.23(@types/node@22.19.1)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/prompts@7.10.1(@types/node@22.19.1)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.1) - '@inquirer/confirm': 5.1.21(@types/node@22.19.1) - '@inquirer/editor': 4.2.23(@types/node@22.19.1) - '@inquirer/expand': 4.0.23(@types/node@22.19.1) - '@inquirer/input': 4.3.1(@types/node@22.19.1) - '@inquirer/number': 3.0.23(@types/node@22.19.1) - '@inquirer/password': 4.0.23(@types/node@22.19.1) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.1) - '@inquirer/search': 3.2.2(@types/node@22.19.1) - '@inquirer/select': 4.4.2(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/prompts@7.3.2(@types/node@22.19.1)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@22.19.1) - '@inquirer/confirm': 5.1.21(@types/node@22.19.1) - '@inquirer/editor': 4.2.23(@types/node@22.19.1) - '@inquirer/expand': 4.0.23(@types/node@22.19.1) - '@inquirer/input': 4.3.1(@types/node@22.19.1) - '@inquirer/number': 3.0.23(@types/node@22.19.1) - '@inquirer/password': 4.0.23(@types/node@22.19.1) - '@inquirer/rawlist': 4.1.11(@types/node@22.19.1) - '@inquirer/search': 3.2.2(@types/node@22.19.1) - '@inquirer/select': 4.4.2(@types/node@22.19.1) - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/rawlist@4.1.11(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/type': 3.0.10(@types/node@22.19.1) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/search@3.2.2(@types/node@22.19.1)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/select@4.4.2(@types/node@22.19.1)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@22.19.1) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@22.19.1) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 22.19.1 - - '@inquirer/type@3.0.10(@types/node@22.19.1)': - optionalDependencies: - '@types/node': 22.19.1 - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.19.1 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.19.1 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.28.5 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.1 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@lukeed/csprng@1.1.0': {} - - '@mapbox/node-pre-gyp@1.0.11': - dependencies: - detect-libc: 2.1.2 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.3 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color - - '@nestjs/cli@11.0.12(@types/node@22.19.1)(esbuild@0.19.12)': - dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@22.19.1)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@22.19.1) - '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) - glob: 12.0.0 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.100.2(esbuild@0.19.12) - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - '@types/node' - - esbuild - - uglify-js - - webpack-cli - - '@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - file-type: 20.4.1 - iterare: 1.2.1 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - class-transformer: 0.5.1 - class-validator: 0.14.3 - transitivePeerDependencies: - - supports-color - - '@nestjs/config@3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - dotenv: 16.4.5 - dotenv-expand: 10.0.0 - lodash: 4.17.21 - rxjs: 7.8.2 - - '@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nuxtjs/opencollective': 0.3.2 - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 3.3.0 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) - transitivePeerDependencies: - - encoding - - '@nestjs/platform-express@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) - body-parser: 1.20.3 - cors: 2.8.5 - express: 4.21.2 - multer: 2.0.2 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - - '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': - dependencies: - '@angular-devkit/core': 19.2.17(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) - comment-json: 4.4.1 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar - - '@nestjs/testing@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) - tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) - - '@nestjs/throttler@6.4.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)': - dependencies: - '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: 0.2.2 - - '@noble/ciphers@2.0.1': {} - - '@noble/hashes@1.8.0': {} - - '@noble/hashes@2.0.1': {} - - '@nuxtjs/opencollective@0.3.2': - dependencies: - chalk: 4.1.2 - consola: 2.15.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@petamoriken/float16@3.9.3': {} - - '@pkgr/core@0.2.9': {} - - '@redis/bloom@1.2.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/client@1.6.1': - dependencies: - cluster-key-slot: 1.1.2 - generic-pool: 3.9.0 - yallist: 4.0.0 - - '@redis/graph@1.1.1(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/json@1.0.7(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/search@1.2.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/time-series@1.1.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@sinclair/typebox@0.27.8': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@so-ric/colorspace@1.1.6': - dependencies: - color: 5.0.3 - text-hex: 1.0.0 - - '@standard-schema/spec@1.0.0': {} - - '@tokenizer/inflate@0.2.7': - dependencies: - debug: 4.4.3 - fflate: 0.8.2 - token-types: 6.1.1 - transitivePeerDependencies: - - supports-color - - '@tokenizer/token@0.3.0': {} - - '@tsconfig/node10@1.0.12': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.28.5 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.28.5 - - '@types/bcrypt@5.0.2': - dependencies: - '@types/node': 22.19.1 - - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 22.19.1 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 22.19.1 - - '@types/cookie-parser@1.4.10(@types/express@5.0.5)': - dependencies: - '@types/express': 5.0.5 - - '@types/cookiejar@2.1.5': {} - - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 - - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - - '@types/estree@1.0.8': {} - - '@types/express-serve-static-core@5.1.0': - dependencies: - '@types/node': 22.19.1 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.5': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.0 - '@types/serve-static': 1.15.10 - - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 22.19.1 - - '@types/http-errors@2.0.5': {} - - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 - - '@types/json-schema@7.0.15': {} - - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.19.1 - - '@types/methods@1.1.4': {} - - '@types/mime@1.3.5': {} - - '@types/ms@2.1.0': {} - - '@types/node@22.19.1': - dependencies: - undici-types: 6.21.0 - - '@types/qs@6.14.0': {} - - '@types/range-parser@1.2.7': {} - - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 22.19.1 - - '@types/send@1.2.1': - dependencies: - '@types/node': 22.19.1 - - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 22.19.1 - '@types/send': 0.17.6 - - '@types/stack-utils@2.0.3': {} - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 22.19.1 - form-data: 4.0.5 - - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 - - '@types/triple-beam@1.3.5': {} - - '@types/validator@13.15.10': {} - - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - - '@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.48.0(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.48.0 - '@typescript-eslint/type-utils': 8.48.0(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.48.0(eslint@9.39.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.48.0 - eslint: 9.39.1 - graphemer: 1.4.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.48.0(eslint@9.39.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.48.0 - '@typescript-eslint/types': 8.48.0 - '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.3 - eslint: 9.39.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.48.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.3) - '@typescript-eslint/types': 8.48.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.48.0': - dependencies: - '@typescript-eslint/types': 8.48.0 - '@typescript-eslint/visitor-keys': 8.48.0 - - '@typescript-eslint/tsconfig-utils@8.48.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.48.0(eslint@9.39.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.48.0 - '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.48.0(eslint@9.39.1)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.1 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.48.0': {} - - '@typescript-eslint/typescript-estree@8.48.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.48.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.3) - '@typescript-eslint/types': 8.48.0 - '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.48.0(eslint@9.39.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) - '@typescript-eslint/scope-manager': 8.48.0 - '@typescript-eslint/types': 8.48.0 - '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) - eslint: 9.39.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.48.0': - dependencies: - '@typescript-eslint/types': 8.48.0 - eslint-visitor-keys: 4.2.1 - - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - - abbrev@1.1.1: {} - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-import-phases@1.0.4(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - ajv-formats@2.1.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - - ajv-keywords@5.1.0(ajv@8.17.1): - dependencies: - ajv: 8.17.1 - fast-deep-equal: 3.1.3 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-colors@4.1.3: {} - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.3: {} - - ansis@4.2.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - append-field@1.0.0: {} - - aproba@2.1.0: {} - - are-we-there-yet@2.0.0: - dependencies: - delegates: 1.0.0 - readable-stream: 3.6.2 - - arg@4.1.3: {} - - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - - argparse@2.0.1: {} - - array-flatten@1.1.1: {} - - array-timsort@1.0.3: {} - - asap@2.0.6: {} - - async@3.2.6: {} - - asynckit@0.4.0: {} - - babel-jest@29.7.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.28.5) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.27.1 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color - - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5) - '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) - - babel-preset-jest@29.6.3(@babel/core@7.28.5): - dependencies: - '@babel/core': 7.28.5 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) - - balanced-match@1.0.2: {} - - base64-js@1.5.1: {} - - baseline-browser-mapping@2.8.31: {} - - bcrypt@5.1.1: - dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - node-addon-api: 5.1.0 - transitivePeerDependencies: - - encoding - - supports-color - - better-auth@1.4.1: - dependencies: - '@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.2)(kysely@0.28.8)(nanostores@1.1.0)) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@noble/ciphers': 2.0.1 - '@noble/hashes': 2.0.1 - '@standard-schema/spec': 1.0.0 - better-call: 1.1.0 - defu: 6.1.4 - jose: 6.1.2 - kysely: 0.28.8 - nanostores: 1.1.0 - zod: 4.1.13 - - better-call@1.1.0: - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - rou3: 0.5.1 - set-cookie-parser: 2.7.2 - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.0: - dependencies: - baseline-browser-mapping: 2.8.31 - caniuse-lite: 1.0.30001757 - electron-to-chromium: 1.5.260 - node-releases: 2.0.27 - update-browserslist-db: 1.1.4(browserslist@4.28.0) - - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 - - buffer-equal-constant-time@1.0.1: {} - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001757: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - char-regex@1.0.2: {} - - chardet@2.1.1: {} - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@2.0.0: {} - - chrome-trace-event@1.0.4: {} - - ci-info@3.9.0: {} - - cjs-module-lexer@1.4.3: {} - - class-transformer@0.5.1: {} - - class-validator@0.14.3: - dependencies: - '@types/validator': 13.15.10 - libphonenumber-js: 1.12.29 - validator: 13.15.23 - - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - cli-width@4.1.0: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone@1.0.4: {} - - cluster-key-slot@1.1.2: {} - - co@4.6.0: {} - - collect-v8-coverage@1.0.3: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-convert@3.1.3: - dependencies: - color-name: 2.1.0 - - color-name@1.1.4: {} - - color-name@2.1.0: {} - - color-string@2.1.4: - dependencies: - color-name: 2.1.0 - - color-support@1.1.3: {} - - color@5.0.3: - dependencies: - color-convert: 3.1.3 - color-string: 2.1.4 - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@2.20.3: {} - - commander@4.1.1: {} - - comment-json@4.4.1: - dependencies: - array-timsort: 1.0.3 - core-util-is: 1.0.3 - esprima: 4.0.1 - - component-emitter@1.3.1: {} - - concat-map@0.0.1: {} - - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - - consola@2.15.3: {} - - console-control-strings@1.1.0: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-parser@1.4.7: - dependencies: - cookie: 0.7.2 - cookie-signature: 1.0.6 - - cookie-signature@1.0.6: {} - - cookie@0.7.1: {} - - cookie@0.7.2: {} - - cookiejar@2.1.4: {} - - core-util-is@1.0.3: {} - - cors@2.8.5: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cosmiconfig@8.3.6(typescript@5.9.3): - dependencies: - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.9.3 - - create-jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - create-require@1.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - dedent@1.7.0: {} - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - defaults@1.0.4: - dependencies: - clone: 1.0.4 - - defu@6.1.4: {} - - delayed-stream@1.0.0: {} - - delegates@1.0.0: {} - - depd@2.0.0: {} - - destroy@1.2.0: {} - - detect-libc@2.1.2: {} - - detect-newline@3.1.0: {} - - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 - - diff-sequences@29.6.3: {} - - diff@4.0.2: {} - - dotenv-expand@10.0.0: {} - - dotenv@16.4.5: {} - - drizzle-kit@0.30.6: - dependencies: - '@drizzle-team/brocli': 0.10.2 - '@esbuild-kit/esm-loader': 2.6.5 - esbuild: 0.19.12 - esbuild-register: 3.6.0(esbuild@0.19.12) - gel: 2.2.0 - transitivePeerDependencies: - - supports-color - - drizzle-orm@0.38.4(kysely@0.28.8)(postgres@3.4.7): - optionalDependencies: - kysely: 0.28.8 - postgres: 3.4.7 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.260: {} - - emittery@0.13.1: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - enabled@2.0.0: {} - - encodeurl@1.0.2: {} - - encodeurl@2.0.0: {} - - enhanced-resolve@5.18.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - - env-paths@3.0.0: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - esbuild-register@3.6.0(esbuild@0.19.12): - dependencies: - debug: 4.4.3 - esbuild: 0.19.12 - transitivePeerDependencies: - - supports-color - - esbuild@0.18.20: - optionalDependencies: - '@esbuild/android-arm': 0.18.20 - '@esbuild/android-arm64': 0.18.20 - '@esbuild/android-x64': 0.18.20 - '@esbuild/darwin-arm64': 0.18.20 - '@esbuild/darwin-x64': 0.18.20 - '@esbuild/freebsd-arm64': 0.18.20 - '@esbuild/freebsd-x64': 0.18.20 - '@esbuild/linux-arm': 0.18.20 - '@esbuild/linux-arm64': 0.18.20 - '@esbuild/linux-ia32': 0.18.20 - '@esbuild/linux-loong64': 0.18.20 - '@esbuild/linux-mips64el': 0.18.20 - '@esbuild/linux-ppc64': 0.18.20 - '@esbuild/linux-riscv64': 0.18.20 - '@esbuild/linux-s390x': 0.18.20 - '@esbuild/linux-x64': 0.18.20 - '@esbuild/netbsd-x64': 0.18.20 - '@esbuild/openbsd-x64': 0.18.20 - '@esbuild/sunos-x64': 0.18.20 - '@esbuild/win32-arm64': 0.18.20 - '@esbuild/win32-ia32': 0.18.20 - '@esbuild/win32-x64': 0.18.20 - - esbuild@0.19.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@2.0.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@9.1.2(eslint@9.39.1): - dependencies: - eslint: 9.39.1 - - eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1))(eslint@9.39.1)(prettier@3.6.2): - dependencies: - eslint: 9.39.1 - prettier: 3.6.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.11 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 9.1.2(eslint@9.39.1) - - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.39.1: - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.39.1 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 - - esprima@4.0.1: {} - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@4.3.0: {} - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - etag@1.8.1: {} - - events@3.3.0: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - - express@4.21.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-diff@1.3.0: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.0: {} - - fb-watchman@2.0.2: - dependencies: - bser: 2.1.1 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - fecha@4.2.3: {} - - fflate@0.8.2: {} - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - file-type@20.4.1: - dependencies: - '@tokenizer/inflate': 0.2.7 - strtok3: 10.3.4 - token-types: 6.1.1 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - fn.name@1.1.0: {} - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 4.0.3 - cosmiconfig: 8.3.6(typescript@5.9.3) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.9.3 - webpack: 5.100.2(esbuild@0.19.12) - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - formidable@3.5.4: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - - forwarded@0.2.0: {} - - fresh@0.5.2: {} - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - - fs-monkey@1.1.0: {} - - fs.realpath@1.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gauge@3.0.2: - dependencies: - aproba: 2.1.0 - color-support: 1.1.3 - console-control-strings: 1.1.0 - has-unicode: 2.0.1 - object-assign: 4.1.1 - signal-exit: 3.0.7 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wide-align: 1.1.5 - - gel@2.2.0: - dependencies: - '@petamoriken/float16': 3.9.3 - debug: 4.4.3 - env-paths: 3.0.0 - semver: 7.7.3 - shell-quote: 1.8.3 - which: 4.0.0 - transitivePeerDependencies: - - supports-color - - generic-pool@3.9.0: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-package-type@0.1.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@6.0.1: {} - - get-tsconfig@4.13.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob-to-regexp@0.4.1: {} - - glob@12.0.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.1.1 - minimatch: 10.1.1 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - globals@14.0.0: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - has-unicode@2.0.1: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - helmet@8.1.0: {} - - html-escaper@2.0.2: {} - - http-errors@2.0.0: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - human-signals@2.1.0: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.7.0: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-generator-fn@2.1.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-interactive@1.0.0: {} - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - is-unicode-supported@0.1.0: {} - - isexe@2.0.0: {} - - isexe@3.1.1: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - iterare@1.2.1: {} - - jackspeak@4.1.1: - dependencies: - '@isaacs/cliui': 8.0.2 - - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.0 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.28.5 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.1 - ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 22.19.1 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - jest-util: 29.7.0 - - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 - - jest-regex-util@29.6.3: {} - - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.11 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color - - jest-runtime@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color - - jest-snapshot@29.7.0: - dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.3 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.1 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - - jest-worker@27.5.1: - dependencies: - '@types/node': 22.19.1 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest-worker@29.7.0: - dependencies: - '@types/node': 22.19.1 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jose@6.1.2: {} - - js-tokens@4.0.0: {} - - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - jsonc-parser@3.3.1: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsonwebtoken@9.0.2: - dependencies: - jws: 3.2.2 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.3 - - jwa@1.4.2: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@3.2.2: - dependencies: - jwa: 1.4.2 - safe-buffer: 5.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@3.0.3: {} - - kuler@2.0.0: {} - - kysely@0.28.8: {} - - leven@3.1.0: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - libphonenumber-js@1.12.29: {} - - lines-and-columns@1.2.4: {} - - loader-runner@4.3.1: {} - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - - lodash.memoize@4.1.2: {} - - lodash.merge@4.6.2: {} - - lodash.once@4.1.1: {} - - lodash@4.17.21: {} - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - logform@2.7.0: - dependencies: - '@colors/colors': 1.6.0 - '@types/triple-beam': 1.3.5 - fecha: 4.2.3 - ms: 2.1.3 - safe-stable-stringify: 2.5.0 - triple-beam: 1.4.1 - - lru-cache@11.2.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - magic-string@0.30.17: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - make-dir@3.1.0: - dependencies: - semver: 6.3.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.3 - - make-error@1.3.6: {} - - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - - math-intrinsics@1.1.0: {} - - media-typer@0.3.0: {} - - memfs@3.5.3: - dependencies: - fs-monkey: 1.1.0 - - merge-descriptors@1.0.3: {} - - merge-stream@2.0.0: {} - - methods@1.1.2: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@1.6.0: {} - - mime@2.6.0: {} - - mimic-fn@2.1.0: {} - - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} - - minipass@7.1.2: {} - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - mkdirp@1.0.4: {} - - ms@2.0.0: {} - - ms@2.1.3: {} - - multer@2.0.2: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 - - mute-stream@2.0.0: {} - - nanoid@5.1.6: {} - - nanostores@1.1.0: {} - - natural-compare@1.4.0: {} - - negotiator@0.6.3: {} - - neo-async@2.6.2: {} - - node-abort-controller@3.1.1: {} - - node-addon-api@5.1.0: {} - - node-emoji@1.11.0: - dependencies: - lodash: 4.17.21 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - node-int64@0.4.0: {} - - node-releases@2.0.27: {} - - nopt@5.0.0: - dependencies: - abbrev: 1.1.1 - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - npmlog@5.0.1: - dependencies: - are-we-there-yet: 2.0.0 - console-control-strings: 1.1.0 - gauge: 3.0.2 - set-blocking: 2.0.0 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - one-time@1.0.0: - dependencies: - fn.name: 1.1.0 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-try@2.2.0: {} - - package-json-from-dist@1.0.1: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-scurry@2.0.1: - dependencies: - lru-cache: 11.2.2 - minipass: 7.1.2 - - path-to-regexp@0.1.12: {} - - path-to-regexp@3.3.0: {} - - path-type@4.0.0: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.2: {} - - picomatch@4.0.3: {} - - pirates@4.0.7: {} - - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - - pluralize@8.0.0: {} - - postgres@3.4.7: {} - - prelude-ls@1.2.1: {} - - prettier-linter-helpers@1.0.0: - dependencies: - fast-diff: 1.3.0 - - prettier@3.6.2: {} - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - punycode@2.3.1: {} - - pure-rand@6.1.0: {} - - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - - range-parser@1.2.1: {} - - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - react-is@18.3.1: {} - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@4.1.2: {} - - redis@4.7.1: - dependencies: - '@redis/bloom': 1.2.0(@redis/client@1.6.1) - '@redis/client': 1.6.1 - '@redis/graph': 1.1.1(@redis/client@1.6.1) - '@redis/json': 1.0.7(@redis/client@1.6.1) - '@redis/search': 1.2.0(@redis/client@1.6.1) - '@redis/time-series': 1.1.0(@redis/client@1.6.1) - - reflect-metadata@0.2.2: {} - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - - rou3@0.5.1: {} - - rxjs@7.8.1: - dependencies: - tslib: 2.8.1 - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} - - safe-stable-stringify@2.5.0: {} - - safer-buffer@2.1.2: {} - - schema-utils@3.3.0: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - schema-utils@4.3.3: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) - - semver@6.3.1: {} - - semver@7.7.3: {} - - send@0.19.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 - - serve-static@1.16.2: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.0 - transitivePeerDependencies: - - supports-color - - set-blocking@2.0.0: {} - - set-cookie-parser@2.7.2: {} - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - shell-quote@1.8.3: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - sisteransi@1.0.5: {} - - slash@3.0.0: {} - - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - source-map@0.7.4: {} - - source-map@0.7.6: {} - - sprintf-js@1.0.3: {} - - stack-trace@0.0.10: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - - statuses@2.0.1: {} - - streamsearch@1.1.0: {} - - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - strip-bom@3.0.0: {} - - strip-bom@4.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - - stripe@17.7.0: - dependencies: - '@types/node': 22.19.1 - qs: 6.14.0 - - strtok3@10.3.4: - dependencies: - '@tokenizer/token': 0.3.0 - - superagent@10.2.3: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 3.5.4 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.14.0 - transitivePeerDependencies: - - supports-color - - supertest@7.1.4: - dependencies: - methods: 1.1.2 - superagent: 10.2.3 - transitivePeerDependencies: - - supports-color - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - symbol-observable@4.0.0: {} - - synckit@0.11.11: - dependencies: - '@pkgr/core': 0.2.9 - - tapable@2.3.0: {} - - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.44.1 - webpack: 5.100.2(esbuild@0.19.12) - optionalDependencies: - esbuild: 0.19.12 - - terser@5.44.1: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 - commander: 2.20.3 - source-map-support: 0.5.21 - - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - - text-hex@1.0.0: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tmpl@1.0.5: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - token-types@6.1.1: - dependencies: - '@borewit/text-codec': 0.1.1 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 - - tr46@0.0.3: {} - - triple-beam@1.4.1: {} - - ts-api-utils@2.1.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.3 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.5 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) - esbuild: 0.19.12 - jest-util: 29.7.0 - - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - micromatch: 4.0.8 - semver: 7.7.3 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.100.2(esbuild@0.19.12) - - ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.19.1 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - tsconfig-paths-webpack-plugin@4.2.0: - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - tapable: 2.3.0 - tsconfig-paths: 4.2.0 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - tsx@4.20.6: - dependencies: - esbuild: 0.25.12 - get-tsconfig: 4.13.0 - optionalDependencies: - fsevents: 2.3.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - typedarray@0.0.6: {} - - typescript@5.9.3: {} - - uglify-js@3.19.3: - optional: true - - uid@2.0.2: - dependencies: - '@lukeed/csprng': 1.1.0 - - uint8array-extras@1.5.0: {} - - undici-types@6.21.0: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - update-browserslist-db@1.1.4(browserslist@4.28.0): - dependencies: - browserslist: 4.28.0 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - util-deprecate@1.0.2: {} - - utils-merge@1.0.1: {} - - v8-compile-cache-lib@3.0.1: {} - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - - validator@13.15.23: {} - - vary@1.1.2: {} - - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - - watchpack@2.4.4: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - - webidl-conversions@3.0.1: {} - - webpack-node-externals@3.0.0: {} - - webpack-sources@3.3.3: {} - - webpack@5.100.2(esbuild@0.19.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@4.0.0: - dependencies: - isexe: 3.1.1 - - wide-align@1.1.5: - dependencies: - string-width: 4.2.3 - - winston-transport@4.9.0: - dependencies: - logform: 2.7.0 - readable-stream: 3.6.2 - triple-beam: 1.4.1 - - winston@3.18.3: - dependencies: - '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.8 - async: 3.2.6 - is-stream: 2.0.1 - logform: 2.7.0 - one-time: 1.0.0 - readable-stream: 3.6.2 - safe-stable-stringify: 2.5.0 - stack-trace: 0.0.10 - triple-beam: 1.4.1 - winston-transport: 4.9.0 - - word-wrap@1.2.5: {} - - wordwrap@1.0.0: {} - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - - xtend@4.0.2: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yallist@4.0.0: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yn@3.1.1: {} - - yocto-queue@0.1.0: {} - - yoctocolors-cjs@2.1.3: {} - - zod@3.25.76: {} - - zod@4.1.13: {} diff --git a/services/mana-core-auth/postgres/init/01-init-schemas.sql b/services/mana-core-auth/postgres/init/01-init-schemas.sql deleted file mode 100644 index db003d7fb..000000000 --- a/services/mana-core-auth/postgres/init/01-init-schemas.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Create schemas -CREATE SCHEMA IF NOT EXISTS auth; -CREATE SCHEMA IF NOT EXISTS credits; - --- Enable necessary extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - --- Create enums -CREATE TYPE auth.user_role AS ENUM ('user', 'admin', 'service'); -CREATE TYPE credits.transaction_type AS ENUM ('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment'); -CREATE TYPE credits.transaction_status AS ENUM ('pending', 'completed', 'failed', 'cancelled'); - --- Grant usage on schemas -GRANT USAGE ON SCHEMA auth TO PUBLIC; -GRANT USAGE ON SCHEMA credits TO PUBLIC; - --- Create updated_at trigger function -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; - -COMMENT ON SCHEMA auth IS 'Authentication and user management'; -COMMENT ON SCHEMA credits IS 'Credit system and transactions'; diff --git a/services/mana-core-auth/postgres/init/02-init-rls.sql b/services/mana-core-auth/postgres/init/02-init-rls.sql deleted file mode 100644 index b0e365ee3..000000000 --- a/services/mana-core-auth/postgres/init/02-init-rls.sql +++ /dev/null @@ -1,67 +0,0 @@ --- Enable Row Level Security on auth tables -ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.passwords ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.two_factor_auth ENABLE ROW LEVEL SECURITY; - --- Enable Row Level Security on credits tables -ALTER TABLE credits.balances ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.transactions ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.purchases ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.usage_stats ENABLE ROW LEVEL SECURITY; - --- RLS Policies for users table -CREATE POLICY "Users can view their own profile" - ON auth.users - FOR SELECT - USING (auth.uid() = id OR auth.role() = 'admin'); - -CREATE POLICY "Users can update their own profile" - ON auth.users - FOR UPDATE - USING (auth.uid() = id) - WITH CHECK (auth.uid() = id); - --- RLS Policies for sessions table -CREATE POLICY "Users can view their own sessions" - ON auth.sessions - FOR SELECT - USING (auth.uid() = user_id OR auth.role() = 'admin'); - -CREATE POLICY "Users can delete their own sessions" - ON auth.sessions - FOR DELETE - USING (auth.uid() = user_id); - --- RLS Policies for balances table -CREATE POLICY "Users can view their own balance" - ON credits.balances - FOR SELECT - USING (auth.uid() = user_id OR auth.role() = 'admin'); - --- RLS Policies for transactions table -CREATE POLICY "Users can view their own transactions" - ON credits.transactions - FOR SELECT - USING (auth.uid() = user_id OR auth.role() = 'admin'); - --- RLS Policies for purchases table -CREATE POLICY "Users can view their own purchases" - ON credits.purchases - FOR SELECT - USING (auth.uid() = user_id OR auth.role() = 'admin'); - --- RLS Policies for usage_stats table -CREATE POLICY "Users can view their own usage stats" - ON credits.usage_stats - FOR SELECT - USING (auth.uid() = user_id OR auth.role() = 'admin'); - --- Helper functions for RLS -CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID AS $$ - SELECT NULLIF(current_setting('request.jwt.claims', true)::json->>'sub', '')::UUID; -$$ LANGUAGE SQL STABLE; - -CREATE OR REPLACE FUNCTION auth.role() RETURNS TEXT AS $$ - SELECT NULLIF(current_setting('request.jwt.claims', true)::json->>'role', '')::TEXT; -$$ LANGUAGE SQL STABLE; diff --git a/services/mana-core-auth/postgres/init/03-organization-rls.sql b/services/mana-core-auth/postgres/init/03-organization-rls.sql deleted file mode 100644 index 270dc5b62..000000000 --- a/services/mana-core-auth/postgres/init/03-organization-rls.sql +++ /dev/null @@ -1,247 +0,0 @@ --- ===================================================== --- RLS POLICIES FOR BETTER AUTH ORGANIZATION TABLES --- ===================================================== - --- Enable RLS on organization tables -ALTER TABLE auth.organizations ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.members ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.invitations ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.organization_balances ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.credit_allocations ENABLE ROW LEVEL SECURITY; - --- ===================================================== --- HELPER FUNCTIONS FOR ORGANIZATION RLS --- ===================================================== - --- Get user's role in an organization -CREATE OR REPLACE FUNCTION auth.user_organization_role(org_id TEXT) RETURNS TEXT AS $$ - SELECT role FROM auth.members - WHERE organization_id = org_id - AND user_id = auth.uid()::text - LIMIT 1; -$$ LANGUAGE SQL STABLE SECURITY DEFINER; - --- Check if user is member of organization -CREATE OR REPLACE FUNCTION auth.is_organization_member(org_id TEXT) RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT 1 FROM auth.members - WHERE organization_id = org_id - AND user_id = auth.uid()::text - ); -$$ LANGUAGE SQL STABLE SECURITY DEFINER; - --- Check if user is owner or admin of organization -CREATE OR REPLACE FUNCTION auth.is_organization_owner_or_admin(org_id TEXT) RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT 1 FROM auth.members - WHERE organization_id = org_id - AND user_id = auth.uid()::text - AND role IN ('owner', 'admin') - ); -$$ LANGUAGE SQL STABLE SECURITY DEFINER; - --- Check if user is owner of organization -CREATE OR REPLACE FUNCTION auth.is_organization_owner(org_id TEXT) RETURNS BOOLEAN AS $$ - SELECT EXISTS( - SELECT 1 FROM auth.members - WHERE organization_id = org_id - AND user_id = auth.uid()::text - AND role = 'owner' - ); -$$ LANGUAGE SQL STABLE SECURITY DEFINER; - --- ===================================================== --- ORGANIZATIONS TABLE POLICIES --- ===================================================== - --- Users can view organizations they are members of -CREATE POLICY "Users can view their organizations" - ON auth.organizations - FOR SELECT - USING ( - auth.is_organization_member(id) - OR auth.role() = 'admin' - ); - --- Users can create organizations (Better Auth will handle adding them as owner) -CREATE POLICY "Users can create organizations" - ON auth.organizations - FOR INSERT - WITH CHECK (true); - --- Only owners can update organization -CREATE POLICY "Owners can update their organizations" - ON auth.organizations - FOR UPDATE - USING (auth.is_organization_owner(id)) - WITH CHECK (auth.is_organization_owner(id)); - --- Only owners can delete organization -CREATE POLICY "Owners can delete their organizations" - ON auth.organizations - FOR DELETE - USING (auth.is_organization_owner(id)); - --- ===================================================== --- MEMBERS TABLE POLICIES --- ===================================================== - --- Members can view other members in their organizations -CREATE POLICY "Members can view organization members" - ON auth.members - FOR SELECT - USING ( - auth.is_organization_member(organization_id) - OR auth.role() = 'admin' - ); - --- Owners and admins can add members (Better Auth handles invitation flow) -CREATE POLICY "Owners and admins can add members" - ON auth.members - FOR INSERT - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Owners and admins can update member roles -CREATE POLICY "Owners and admins can update members" - ON auth.members - FOR UPDATE - USING (auth.is_organization_owner_or_admin(organization_id)) - WITH CHECK (auth.is_organization_owner_or_admin(organization_id)); - --- Owners and admins can remove members --- Members can remove themselves -CREATE POLICY "Owners/admins can remove members, members can leave" - ON auth.members - FOR DELETE - USING ( - auth.is_organization_owner_or_admin(organization_id) - OR user_id = auth.uid()::text - OR auth.role() = 'admin' - ); - --- ===================================================== --- INVITATIONS TABLE POLICIES --- ===================================================== - --- Members can view invitations in their organizations -CREATE POLICY "Members can view organization invitations" - ON auth.invitations - FOR SELECT - USING ( - auth.is_organization_member(organization_id) - OR email = (SELECT email FROM auth.users WHERE id = auth.uid()) - OR auth.role() = 'admin' - ); - --- Owners and admins can create invitations -CREATE POLICY "Owners and admins can create invitations" - ON auth.invitations - FOR INSERT - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Owners and admins can update invitations (cancel, etc) -CREATE POLICY "Owners and admins can update invitations" - ON auth.invitations - FOR UPDATE - USING ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ) - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Inviter can delete their invitations --- Invitee can delete (reject) invitations sent to them -CREATE POLICY "Inviters and invitees can delete invitations" - ON auth.invitations - FOR DELETE - USING ( - inviter_id = auth.uid()::text - OR email = (SELECT email FROM auth.users WHERE id = auth.uid()) - OR auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- ===================================================== --- ORGANIZATION BALANCES TABLE POLICIES --- ===================================================== - --- Members can view their organization's balance -CREATE POLICY "Members can view organization balance" - ON credits.organization_balances - FOR SELECT - USING ( - auth.is_organization_member(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners can create organization balances (during org creation) -CREATE POLICY "Owners can create organization balance" - ON credits.organization_balances - FOR INSERT - WITH CHECK ( - auth.is_organization_owner(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners can update organization balances (allocations, purchases) -CREATE POLICY "Owners can update organization balance" - ON credits.organization_balances - FOR UPDATE - USING (auth.is_organization_owner(organization_id)) - WITH CHECK (auth.is_organization_owner(organization_id)); - --- Only owners can delete (cascade handled by org deletion) -CREATE POLICY "Owners can delete organization balance" - ON credits.organization_balances - FOR DELETE - USING (auth.is_organization_owner(organization_id)); - --- ===================================================== --- CREDIT ALLOCATIONS TABLE POLICIES --- ===================================================== - --- Employees can view allocations to them --- Owners/admins can view all allocations in their org -CREATE POLICY "Users can view relevant credit allocations" - ON credits.credit_allocations - FOR SELECT - USING ( - employee_id = auth.uid() - OR auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners can create credit allocations -CREATE POLICY "Owners can create credit allocations" - ON credits.credit_allocations - FOR INSERT - WITH CHECK ( - auth.is_organization_owner(organization_id) - OR auth.role() = 'admin' - ); - --- No updates to allocations (immutable audit trail) --- No deletes to allocations (immutable audit trail) - --- ===================================================== --- COMMENTS --- ===================================================== - -COMMENT ON POLICY "Users can view their organizations" ON auth.organizations IS 'Members can view organizations they belong to'; -COMMENT ON POLICY "Users can create organizations" ON auth.organizations IS 'Any authenticated user can create an organization'; -COMMENT ON POLICY "Owners can update their organizations" ON auth.organizations IS 'Only owners can modify organization details'; -COMMENT ON POLICY "Owners can delete their organizations" ON auth.organizations IS 'Only owners can delete organizations'; - -COMMENT ON FUNCTION auth.user_organization_role IS 'Returns the role of the current user in the specified organization'; -COMMENT ON FUNCTION auth.is_organization_member IS 'Checks if current user is a member of the organization'; -COMMENT ON FUNCTION auth.is_organization_owner_or_admin IS 'Checks if current user is owner or admin of the organization'; -COMMENT ON FUNCTION auth.is_organization_owner IS 'Checks if current user is owner of the organization'; diff --git a/services/mana-core-auth/postgres/init/04-guild-rls.sql b/services/mana-core-auth/postgres/init/04-guild-rls.sql deleted file mode 100644 index d314249a1..000000000 --- a/services/mana-core-auth/postgres/init/04-guild-rls.sql +++ /dev/null @@ -1,138 +0,0 @@ --- ===================================================== --- RLS POLICIES FOR GUILD POOL TABLES --- ===================================================== --- Uses helper functions from 03-organization-rls.sql: --- auth.is_organization_member(org_id) --- auth.is_organization_owner_or_admin(org_id) --- auth.is_organization_owner(org_id) - --- Enable RLS -ALTER TABLE credits.guild_pools ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.guild_spending_limits ENABLE ROW LEVEL SECURITY; -ALTER TABLE credits.guild_transactions ENABLE ROW LEVEL SECURITY; - --- ===================================================== --- GUILD POOLS TABLE POLICIES --- ===================================================== - --- Members can view their guild's pool balance -CREATE POLICY "Members can view guild pool" - ON credits.guild_pools - FOR SELECT - USING ( - auth.is_organization_member(organization_id) - OR auth.role() = 'admin' - ); - --- Pool is created during guild creation (by owner or system) -CREATE POLICY "Owners can create guild pool" - ON credits.guild_pools - FOR INSERT - WITH CHECK ( - auth.is_organization_owner(organization_id) - OR auth.role() = 'admin' - ); - --- Owners and admins can update pool (funding, spending) -CREATE POLICY "Owners and admins can update guild pool" - ON credits.guild_pools - FOR UPDATE - USING ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ) - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners can delete pool (cascade from org deletion) -CREATE POLICY "Owners can delete guild pool" - ON credits.guild_pools - FOR DELETE - USING ( - auth.is_organization_owner(organization_id) - OR auth.role() = 'admin' - ); - --- ===================================================== --- GUILD SPENDING LIMITS TABLE POLICIES --- ===================================================== - --- Members can view their own limits; owners/admins can view all -CREATE POLICY "Users can view guild spending limits" - ON credits.guild_spending_limits - FOR SELECT - USING ( - user_id = auth.uid()::text - OR auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners and admins can set spending limits -CREATE POLICY "Owners and admins can create spending limits" - ON credits.guild_spending_limits - FOR INSERT - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners and admins can update spending limits -CREATE POLICY "Owners and admins can update spending limits" - ON credits.guild_spending_limits - FOR UPDATE - USING ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ) - WITH CHECK ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Only owners and admins can delete spending limits -CREATE POLICY "Owners and admins can delete spending limits" - ON credits.guild_spending_limits - FOR DELETE - USING ( - auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- ===================================================== --- GUILD TRANSACTIONS TABLE POLICIES --- ===================================================== - --- Members can view their own transactions; owners/admins see all -CREATE POLICY "Users can view guild transactions" - ON credits.guild_transactions - FOR SELECT - USING ( - user_id = auth.uid()::text - OR auth.is_organization_owner_or_admin(organization_id) - OR auth.role() = 'admin' - ); - --- Any member can create transactions (via guild credit usage) -CREATE POLICY "Members can create guild transactions" - ON credits.guild_transactions - FOR INSERT - WITH CHECK ( - auth.is_organization_member(organization_id) - OR auth.role() = 'admin' - ); - --- No updates to transactions (immutable audit trail) --- No deletes to transactions (immutable audit trail) - --- ===================================================== --- COMMENTS --- ===================================================== - -COMMENT ON POLICY "Members can view guild pool" ON credits.guild_pools IS 'Guild members can see the shared pool balance'; -COMMENT ON POLICY "Owners can create guild pool" ON credits.guild_pools IS 'Pool created during guild setup by owner'; -COMMENT ON POLICY "Owners and admins can update guild pool" ON credits.guild_pools IS 'Pool balance updated during funding and spending'; -COMMENT ON POLICY "Users can view guild spending limits" ON credits.guild_spending_limits IS 'Members see own limits, owners/admins see all'; -COMMENT ON POLICY "Users can view guild transactions" ON credits.guild_transactions IS 'Members see own transactions, owners/admins see all'; -COMMENT ON POLICY "Members can create guild transactions" ON credits.guild_transactions IS 'Any guild member can create transactions via credit usage'; diff --git a/services/mana-core-auth/scripts/generate-keys.sh b/services/mana-core-auth/scripts/generate-keys.sh deleted file mode 100755 index 311e6e8dc..000000000 --- a/services/mana-core-auth/scripts/generate-keys.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# Generate RS256 key pair for JWT signing - -echo "Generating RS256 key pair..." - -# Generate private key -openssl genrsa -out private.pem 2048 - -# Generate public key from private key -openssl rsa -in private.pem -pubout -out public.pem - -echo "" -echo "Keys generated successfully!" -echo "" -echo "Private key: private.pem" -echo "Public key: public.pem" -echo "" -echo "Add these to your .env file:" -echo "" -echo "JWT_PRIVATE_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)\"" -echo "" -echo "JWT_PUBLIC_KEY=\"$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)\"" -echo "" -echo "IMPORTANT: Keep private.pem secure and never commit it to version control!" diff --git a/services/mana-core-auth/src/__tests__/utils/mock-factories.ts b/services/mana-core-auth/src/__tests__/utils/mock-factories.ts deleted file mode 100644 index 741bcf267..000000000 --- a/services/mana-core-auth/src/__tests__/utils/mock-factories.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Mock Factories for Testing - * - * Centralized factory functions for creating test data - */ - -import { nanoid } from 'nanoid'; -import * as bcrypt from 'bcryptjs'; - -/** - * Mock User Factory - */ -export const mockUserFactory = { - create: (overrides: Partial = {}) => ({ - id: nanoid(), - email: `test-${nanoid(6)}@example.com`, - emailVerified: true, - name: 'Test User', - avatarUrl: null, - role: 'user', - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - ...overrides, - }), - - createMany: (count: number, overrides: Partial = {}) => { - return Array.from({ length: count }, () => mockUserFactory.create(overrides)); - }, -}; - -/** - * Mock Session Factory - */ -export const mockSessionFactory = { - create: (userId: string, overrides: Partial = {}) => ({ - id: nanoid(), - userId, - token: nanoid(), - refreshToken: nanoid(64), - refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days - ipAddress: '127.0.0.1', - userAgent: 'Mozilla/5.0 Test', - deviceId: null, - deviceName: null, - lastActivityAt: new Date(), - createdAt: new Date(), - expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes - revokedAt: null, - ...overrides, - }), -}; - -/** - * Mock Password Factory - */ -export const mockPasswordFactory = { - create: async (userId: string, password = 'TestPassword123!') => ({ - userId, - hashedPassword: await bcrypt.hash(password, 12), - createdAt: new Date(), - updatedAt: new Date(), - }), - - createSync: (userId: string, password = 'TestPassword123!') => ({ - userId, - hashedPassword: bcrypt.hashSync(password, 12), - createdAt: new Date(), - updatedAt: new Date(), - }), -}; - -/** - * Mock Balance Factory - * Simplified - no free credits or daily reset - */ -export const mockBalanceFactory = { - create: (userId: string, overrides: Partial = {}) => ({ - userId, - balance: 0, - totalEarned: 0, - totalSpent: 0, - version: 0, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), - - withBalance: (userId: string, balance: number) => { - return mockBalanceFactory.create(userId, { - balance, - }); - }, -}; - -/** - * Mock Transaction Factory - */ -export const mockTransactionFactory = { - create: (userId: string, overrides: Partial = {}) => ({ - id: nanoid(), - userId, - type: 'usage', - status: 'completed', - amount: -10, - balanceBefore: 100, - balanceAfter: 90, - appId: 'test-app', - description: 'Test transaction', - metadata: null, - idempotencyKey: null, - createdAt: new Date(), - completedAt: new Date(), - ...overrides, - }), - - createMany: (userId: string, count: number) => { - return Array.from({ length: count }, (_, i) => - mockTransactionFactory.create(userId, { - amount: -(i + 1) * 10, - }) - ); - }, -}; - -/** - * Mock Package Factory - */ -export const mockPackageFactory = { - create: (overrides: Partial = {}) => ({ - id: nanoid(), - name: 'Test Package', - description: '100 credits', - credits: 100, - priceEuroCents: 100, - stripePriceId: `price_${nanoid()}`, - active: true, - sortOrder: 0, - metadata: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), - - createMany: (count: number) => { - return Array.from({ length: count }, (_, i) => - mockPackageFactory.create({ - name: `Package ${i + 1}`, - credits: (i + 1) * 100, - priceEuroCents: (i + 1) * 100, - sortOrder: i, - }) - ); - }, -}; - -/** - * Mock Purchase Factory - */ -export const mockPurchaseFactory = { - create: (userId: string, packageId: string, overrides: Partial = {}) => ({ - id: nanoid(), - userId, - packageId, - credits: 100, - priceEuroCents: 100, - stripePaymentIntentId: `pi_${nanoid()}`, - stripeCustomerId: `cus_${nanoid()}`, - status: 'completed', - metadata: null, - createdAt: new Date(), - completedAt: new Date(), - ...overrides, - }), -}; - -/** - * Mock DTO Factory - */ -export const mockDtoFactory = { - register: (overrides: Partial = {}) => ({ - email: `test-${nanoid(6)}@example.com`, - password: 'SecurePassword123!', - name: 'Test User', - ...overrides, - }), - - login: (overrides: Partial = {}) => ({ - email: 'test@example.com', - password: 'SecurePassword123!', - deviceId: undefined, - deviceName: undefined, - ...overrides, - }), - - useCredits: (overrides: Partial = {}) => ({ - amount: 10, - appId: 'test-app', - description: 'Test operation', - metadata: undefined, - idempotencyKey: undefined, - ...overrides, - }), -}; - -/** - * Mock JWT Tokens - */ -export const mockTokenFactory = { - validPayload: (overrides: Partial = {}) => ({ - sub: nanoid(), - email: 'test@example.com', - role: 'user', - sessionId: nanoid(), - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes - ...overrides, - }), - - expiredPayload: (overrides: Partial = {}) => ({ - sub: nanoid(), - email: 'test@example.com', - role: 'user', - sessionId: nanoid(), - iat: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago - exp: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago (expired) - ...overrides, - }), -}; - -/** - * Mock Organization Factory - */ -export const mockOrganizationFactory = { - create: (overrides: Partial = {}) => ({ - id: nanoid(), - name: 'Test Organization', - slug: `test-org-${nanoid(6)}`, - logo: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), -}; - -/** - * Mock Organization Balance Factory - */ -export const mockOrganizationBalanceFactory = { - create: (organizationId: string, overrides: Partial = {}) => ({ - organizationId, - balance: 0, - allocatedCredits: 0, - availableCredits: 0, - totalPurchased: 0, - totalAllocated: 0, - version: 0, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), - - withBalance: (organizationId: string, balance: number, allocated = 0) => { - return mockOrganizationBalanceFactory.create(organizationId, { - balance, - allocatedCredits: allocated, - availableCredits: balance - allocated, - }); - }, -}; - -/** - * Mock Member Factory - */ -export const mockMemberFactory = { - create: (organizationId: string, userId: string, overrides: Partial = {}) => ({ - id: nanoid(), - organizationId, - userId, - role: 'member', - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), - - createOwner: (organizationId: string, userId: string) => { - return mockMemberFactory.create(organizationId, userId, { - role: 'owner', - }); - }, - - createEmployee: (organizationId: string, userId: string) => { - return mockMemberFactory.create(organizationId, userId, { - role: 'member', - }); - }, -}; - -/** - * Mock Credit Allocation Factory - */ -export const mockCreditAllocationFactory = { - create: ( - organizationId: string, - employeeId: string, - allocatedBy: string, - overrides: Partial = {} - ) => ({ - id: nanoid(), - organizationId, - employeeId, - amount: 100, - allocatedBy, - reason: 'Credit allocation', - balanceBefore: 0, - balanceAfter: 100, - createdAt: new Date(), - ...overrides, - }), -}; - -/** - * Mock Database Responses - */ -export const mockDbFactory = { - createSelectMock: () => ({ - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - returning: jest.fn(), - }), - - createInsertMock: () => ({ - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - }), - - createUpdateMock: () => ({ - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - returning: jest.fn(), - }), - - createTransactionMock: () => ({ - transaction: jest.fn((callback) => callback(mockDbFactory.createSelectMock())), - }), - - createFullMock: () => ({ - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - transaction: jest.fn((callback) => callback(this)), - }), -}; diff --git a/services/mana-core-auth/src/__tests__/utils/silent-error.decorator.ts b/services/mana-core-auth/src/__tests__/utils/silent-error.decorator.ts deleted file mode 100644 index 372468653..000000000 --- a/services/mana-core-auth/src/__tests__/utils/silent-error.decorator.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Test Helper: silentError - * - * Suppresses console.error output for tests that intentionally trigger errors. - * This keeps test output clean while still verifying error handling behavior. - * - * Usage: - * ```typescript - * it('should handle error gracefully', async () => { - * await silentError(async () => { - * // Test code that triggers console.error - * await service.methodThatLogsErrors(); - * }); - * }); - * ``` - */ -export async function silentError(fn: () => T | Promise): Promise { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - try { - return await fn(); - } finally { - consoleErrorSpy.mockRestore(); - } -} - -/** - * Test Helper: silentConsole - * - * Suppresses all console output (log, warn, error) for cleaner test output. - * - * Usage: - * ```typescript - * it('should work without console spam', async () => { - * await silentConsole(async () => { - * // Test code that logs to console - * }); - * }); - * ``` - */ -export async function silentConsole(fn: () => T | Promise): Promise { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - - try { - return await fn(); - } finally { - consoleErrorSpy.mockRestore(); - consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - } -} - -/** - * Test Helper: suppressConsoleInTests - * - * Globally suppress console output for an entire test suite. - * Use in beforeEach/afterEach for suite-wide suppression. - * - * Usage: - * ```typescript - * describe('MyService', () => { - * beforeEach(() => { - * suppressConsoleInTests(); - * }); - * - * afterEach(() => { - * restoreConsoleInTests(); - * }); - * }); - * ``` - */ -let consoleSpies: jest.SpyInstance[] = []; - -export function suppressConsoleInTests() { - consoleSpies = [ - jest.spyOn(console, 'error').mockImplementation(() => {}), - jest.spyOn(console, 'log').mockImplementation(() => {}), - jest.spyOn(console, 'warn').mockImplementation(() => {}), - ]; -} - -export function restoreConsoleInTests() { - consoleSpies.forEach((spy) => spy.mockRestore()); - consoleSpies = []; -} diff --git a/services/mana-core-auth/src/__tests__/utils/test-helpers.ts b/services/mana-core-auth/src/__tests__/utils/test-helpers.ts deleted file mode 100644 index 34b8cdedf..000000000 --- a/services/mana-core-auth/src/__tests__/utils/test-helpers.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Test Helper Utilities - * - * Common utilities for writing tests - */ - -import { type ConfigService } from '@nestjs/config'; - -/** - * Create mock ConfigService - */ -export const createMockConfigService = (overrides: Record = {}): ConfigService => { - const defaultConfig: Record = { - 'database.url': 'postgresql://test:test@localhost:5432/test', - 'jwt.privateKey': 'mock-private-key', - 'jwt.publicKey': 'mock-public-key', - 'jwt.accessTokenExpiry': '15m', - 'jwt.refreshTokenExpiry': '7d', - 'jwt.issuer': 'mana-core', - 'jwt.audience': 'mana-universe', - 'redis.host': 'localhost', - 'redis.port': 6379, - 'redis.password': 'test', - ...overrides, - }; - - return { - get: jest.fn((key: string) => defaultConfig[key]), - getOrThrow: jest.fn((key: string) => { - if (!defaultConfig[key]) { - throw new Error(`Configuration key ${key} not found`); - } - return defaultConfig[key]; - }), - } as unknown as ConfigService; -}; - -/** - * Create a test date with specific offset - */ -export const createTestDate = (offsetMs = 0): Date => { - return new Date(Date.now() + offsetMs); -}; - -/** - * Mock timer utilities - */ -export const timerUtils = { - /** - * Fast-forward time - */ - advance: (ms: number) => { - jest.advanceTimersByTime(ms); - }, - - /** - * Use fake timers - */ - useFake: () => { - jest.useFakeTimers(); - }, - - /** - * Use real timers - */ - useReal: () => { - jest.useRealTimers(); - }, -}; - -/** - * Assert helpers for common patterns - */ -export const assertHelpers = { - /** - * Assert that a function throws a specific error - */ - assertThrowsAsync: async (fn: () => Promise, expectedError: string | RegExp) => { - await expect(fn()).rejects.toThrow(expectedError); - }, - - /** - * Assert that an object has specific properties - */ - assertHasProperties: (obj: any, properties: string[]) => { - properties.forEach((prop) => { - expect(obj).toHaveProperty(prop); - }); - }, - - /** - * Assert that an object does NOT have specific properties - */ - assertLacksProperties: (obj: any, properties: string[]) => { - properties.forEach((prop) => { - expect(obj).not.toHaveProperty(prop); - }); - }, - - /** - * Assert that a value is a valid UUID - */ - assertIsUuid: (value: string) => { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - expect(value).toMatch(uuidRegex); - }, - - /** - * Assert that a date is recent (within last N seconds) - */ - assertIsRecent: (date: Date, withinSeconds = 5) => { - const now = Date.now(); - const dateMs = date.getTime(); - const diff = Math.abs(now - dateMs); - expect(diff).toBeLessThan(withinSeconds * 1000); - }, - - /** - * Assert that a value is between min and max - */ - assertBetween: (value: number, min: number, max: number) => { - expect(value).toBeGreaterThanOrEqual(min); - expect(value).toBeLessThanOrEqual(max); - }, -}; - -/** - * Database test helpers - */ -export const dbTestHelpers = { - /** - * Create a mock database connection - */ - createMockDb: () => ({ - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - transaction: jest.fn(), - }), - - /** - * Mock successful query result - */ - mockSuccessResult: (data: any) => ({ - data, - error: null, - }), - - /** - * Mock error result - */ - mockErrorResult: (error: Error) => ({ - data: null, - error, - }), -}; - -/** - * Security test helpers - */ -export const securityTestHelpers = { - /** - * Common SQL injection payloads - */ - sqlInjectionPayloads: [ - "'; DROP TABLE users; --", - "' OR '1'='1", - "' OR '1'='1' --", - "' OR '1'='1' /*", - "admin'--", - "' UNION SELECT NULL--", - ], - - /** - * Common XSS payloads - */ - xssPayloads: [ - '', - '', - '', - 'javascript:alert("xss")', - ], - - /** - * Test for timing attacks - */ - measureExecutionTime: async (fn: () => Promise): Promise => { - const start = process.hrtime.bigint(); - await fn(); - const end = process.hrtime.bigint(); - return Number(end - start) / 1_000_000; // Convert to milliseconds - }, - - /** - * Test for constant-time comparison - */ - isConstantTime: async ( - fn1: () => Promise, - fn2: () => Promise, - threshold = 10 - ): Promise => { - const time1 = await securityTestHelpers.measureExecutionTime(fn1); - const time2 = await securityTestHelpers.measureExecutionTime(fn2); - const diff = Math.abs(time1 - time2); - return diff < threshold; - }, -}; - -/** - * Mock HTTP request/response - */ -export const httpMockHelpers = { - /** - * Create mock Express request - */ - createMockRequest: (overrides: Partial = {}) => ({ - headers: {}, - body: {}, - query: {}, - params: {}, - ip: '127.0.0.1', - user: null, - ...overrides, - }), - - /** - * Create mock Express response - */ - createMockResponse: () => { - const res: any = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), - end: jest.fn().mockReturnThis(), - }; - return res; - }, - - /** - * Create mock NestJS ExecutionContext - */ - createMockExecutionContext: (request: any) => ({ - switchToHttp: () => ({ - getRequest: () => request, - getResponse: () => httpMockHelpers.createMockResponse(), - }), - getClass: () => ({}), - getHandler: () => ({}), - }), -}; - -/** - * Performance test helpers - */ -export const performanceHelpers = { - /** - * Run a function N times and measure average execution time - */ - benchmark: async (fn: () => Promise, iterations = 100): Promise => { - const times: number[] = []; - - for (let i = 0; i < iterations; i++) { - const start = process.hrtime.bigint(); - await fn(); - const end = process.hrtime.bigint(); - times.push(Number(end - start) / 1_000_000); - } - - const avg = times.reduce((a, b) => a + b, 0) / times.length; - return avg; - }, - - /** - * Assert function execution is under a time limit - */ - assertExecutionTime: async (fn: () => Promise, maxMs: number) => { - const start = process.hrtime.bigint(); - await fn(); - const end = process.hrtime.bigint(); - const duration = Number(end - start) / 1_000_000; - expect(duration).toBeLessThan(maxMs); - }, -}; diff --git a/services/mana-core-auth/src/admin/admin.module.ts b/services/mana-core-auth/src/admin/admin.module.ts deleted file mode 100644 index c3b78b6ba..000000000 --- a/services/mana-core-auth/src/admin/admin.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { ConfigModule } from '@nestjs/config'; -import { UserDataController } from './user-data.controller'; -import { UserDataService } from './user-data.service'; -import { AdminGuard } from './guards/admin.guard'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [ - ConfigModule, - HttpModule.register({ - timeout: 5000, - maxRedirects: 3, - }), - AuthModule, - ], - controllers: [UserDataController], - providers: [UserDataService, AdminGuard], - exports: [UserDataService], -}) -export class AdminModule {} diff --git a/services/mana-core-auth/src/admin/dto/user-data.dto.ts b/services/mana-core-auth/src/admin/dto/user-data.dto.ts deleted file mode 100644 index 1749278eb..000000000 --- a/services/mana-core-auth/src/admin/dto/user-data.dto.ts +++ /dev/null @@ -1,78 +0,0 @@ -export interface EntityCount { - entity: string; - count: number; - label: string; -} - -export interface ProjectDataSummary { - projectId: string; - projectName: string; - icon: string; - available: boolean; - error?: string; - entities: EntityCount[]; - totalCount: number; - lastActivityAt?: string; -} - -export interface UserDataSummary { - user: { - id: string; - email: string; - name: string; - role: string; - createdAt: string; - emailVerified: boolean; - }; - auth: { - sessionsCount: number; - accountsCount: number; - has2FA: boolean; - lastLoginAt: string | null; - }; - credits: { - balance: number; - totalEarned: number; - totalSpent: number; - transactionsCount: number; - }; - projects: ProjectDataSummary[]; - totals: { - totalEntities: number; - projectsWithData: number; - }; -} - -export interface DeleteUserDataResponse { - success: boolean; - deletedFromProjects: { - projectId: string; - projectName: string; - success: boolean; - error?: string; - deletedCount?: number; - }[]; - deletedFromAuth: { - sessions: number; - accounts: number; - credits: number; - user: boolean; - }; - totalDeleted: number; -} - -export interface UserListItem { - id: string; - email: string; - name: string; - role: string; - createdAt: string; - lastActiveAt?: string; -} - -export interface UserListResponse { - users: UserListItem[]; - total: number; - page: number; - limit: number; -} diff --git a/services/mana-core-auth/src/admin/guards/admin.guard.ts b/services/mana-core-auth/src/admin/guards/admin.guard.ts deleted file mode 100644 index f28c13b0b..000000000 --- a/services/mana-core-auth/src/admin/guards/admin.guard.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - ForbiddenException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; -import { BetterAuthService } from '../../auth/services/better-auth.service'; - -/** - * Guard for admin-only endpoints - * Checks JWT token and verifies user has admin role or is in ADMIN_USER_IDS - */ -@Injectable() -export class AdminGuard implements CanActivate { - private readonly logger = new Logger(AdminGuard.name); - private readonly adminUserIds: string[]; - - constructor( - private readonly configService: ConfigService, - private readonly betterAuthService: BetterAuthService - ) { - const adminIds = this.configService.get('ADMIN_USER_IDS', ''); - this.adminUserIds = adminIds ? adminIds.split(',').map((id) => id.trim()) : []; - } - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const authHeader = request.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - this.logger.warn('Missing or invalid Authorization header'); - throw new UnauthorizedException('Missing authorization token'); - } - - const token = authHeader.substring(7); - - try { - // Validate JWT using Better Auth - const result = await this.betterAuthService.validateToken(token); - - if (!result.valid || !result.payload) { - throw new UnauthorizedException('Invalid token'); - } - - const userId = result.payload.sub; - const userRole = result.payload.role; - - // Check if user is admin (by role or by explicit ID) - const isAdmin = userRole === 'admin' || this.adminUserIds.includes(userId); - - if (!isAdmin) { - this.logger.warn(`User ${userId} attempted admin access without permission`); - throw new ForbiddenException('Admin access required'); - } - - // Attach user info to request - (request as any).user = result.payload; - - return true; - } catch (error) { - if (error instanceof UnauthorizedException || error instanceof ForbiddenException) { - throw error; - } - this.logger.error(`Token validation error: ${error.message}`); - throw new UnauthorizedException('Token validation failed'); - } - } -} diff --git a/services/mana-core-auth/src/admin/user-data.controller.ts b/services/mana-core-auth/src/admin/user-data.controller.ts deleted file mode 100644 index 0a7e0cbb4..000000000 --- a/services/mana-core-auth/src/admin/user-data.controller.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - Controller, - Get, - Delete, - Param, - Query, - UseGuards, - Logger, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { UserDataService } from './user-data.service'; -import { AdminGuard } from './guards/admin.guard'; -import { UserDataSummary, DeleteUserDataResponse, UserListResponse } from './dto/user-data.dto'; - -/** - * Admin controller for cross-project user data management - * All endpoints require admin authentication (role=admin or in ADMIN_USER_IDS) - */ -@Controller('api/v1/admin') -@UseGuards(AdminGuard) -export class UserDataController { - private readonly logger = new Logger(UserDataController.name); - - constructor(private readonly userDataService: UserDataService) {} - - /** - * List all users with pagination and search - * GET /api/v1/admin/users?page=1&limit=20&search=email - */ - @Get('users') - async getUsers( - @Query('page') page?: string, - @Query('limit') limit?: string, - @Query('search') search?: string - ): Promise { - const pageNum = parseInt(page || '1', 10); - const limitNum = Math.min(parseInt(limit || '20', 10), 100); - - this.logger.log( - `Admin request: getUsers page=${pageNum} limit=${limitNum} search=${search || ''}` - ); - return this.userDataService.getUsers(pageNum, limitNum, search); - } - - /** - * Get aggregated user data from all projects - * GET /api/v1/admin/users/:userId/data - */ - @Get('users/:userId/data') - async getUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: getUserData for userId=${userId}`); - return this.userDataService.getUserDataSummary(userId); - } - - /** - * Delete all user data across all projects (GDPR right to be forgotten) - * DELETE /api/v1/admin/users/:userId/data - */ - @Delete('users/:userId/data') - @HttpCode(HttpStatus.OK) - async deleteUserData(@Param('userId') userId: string): Promise { - this.logger.log(`Admin request: deleteUserData for userId=${userId}`); - return this.userDataService.deleteUserData(userId); - } -} diff --git a/services/mana-core-auth/src/admin/user-data.service.ts b/services/mana-core-auth/src/admin/user-data.service.ts deleted file mode 100644 index 973cfd7b4..000000000 --- a/services/mana-core-auth/src/admin/user-data.service.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom } from 'rxjs'; -import { eq, sql, desc, ilike, or } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import * as schema from '../db/schema'; -import { - UserDataSummary, - ProjectDataSummary, - DeleteUserDataResponse, - UserListItem, - UserListResponse, -} from './dto/user-data.dto'; - -interface ProjectConfig { - id: string; - name: string; - icon: string; - url: string; -} - -@Injectable() -export class UserDataService { - private readonly logger = new Logger(UserDataService.name); - private readonly serviceKey: string; - private readonly projectConfigs: ProjectConfig[]; - - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService - ) { - this.serviceKey = this.configService.get('ADMIN_SERVICE_KEY', 'dev-admin-key'); - - // Configure backend URLs from environment or use defaults - this.projectConfigs = this.initProjectConfigs(); - } - - private getDatabase() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - private initProjectConfigs(): ProjectConfig[] { - return [ - { - id: 'chat', - name: 'Chat', - icon: '💬', - url: this.configService.get('CHAT_BACKEND_URL', 'http://localhost:3002'), - }, - { - id: 'todo', - name: 'Todo', - icon: '✅', - url: this.configService.get('TODO_BACKEND_URL', 'http://localhost:3018'), - }, - { - id: 'contacts', - name: 'Contacts', - icon: '👥', - url: this.configService.get('CONTACTS_BACKEND_URL', 'http://localhost:3015'), - }, - { - id: 'calendar', - name: 'Calendar', - icon: '📅', - url: this.configService.get('CALENDAR_BACKEND_URL', 'http://localhost:3014'), - }, - { - id: 'picture', - name: 'Picture', - icon: '🎨', - url: this.configService.get('PICTURE_BACKEND_URL', 'http://localhost:3006'), - }, - { - id: 'zitare', - name: 'Zitare', - icon: '💡', - url: this.configService.get('ZITARE_BACKEND_URL', 'http://localhost:3007'), - }, - { - id: 'presi', - name: 'Presi', - icon: '📊', - url: this.configService.get('PRESI_BACKEND_URL', 'http://localhost:3008'), - }, - { - id: 'photos', - name: 'Photos', - icon: '📷', - url: this.configService.get('PHOTOS_BACKEND_URL', 'http://localhost:3019'), - }, - { - id: 'clock', - name: 'Clock', - icon: '⏰', - url: this.configService.get('CLOCK_BACKEND_URL', 'http://localhost:3017'), - }, - { - id: 'storage', - name: 'Storage', - icon: '💾', - url: this.configService.get('STORAGE_BACKEND_URL', 'http://localhost:3016'), - }, - ]; - } - - /** - * Get list of all users with pagination - */ - async getUsers(page: number = 1, limit: number = 20, search?: string): Promise { - const db = this.getDatabase(); - const offset = (page - 1) * limit; - - // Build base query - const baseConditions = search - ? or(ilike(schema.users.email, `%${search}%`), ilike(schema.users.name, `%${search}%`)) - : undefined; - - const [users, countResult] = await Promise.all([ - db - .select() - .from(schema.users) - .where(baseConditions) - .orderBy(desc(schema.users.createdAt)) - .limit(limit) - .offset(offset), - db - .select({ count: sql`count(*)::int` }) - .from(schema.users) - .where(baseConditions), - ]); - - // Get last session for each user - const userIds = users.map((user: typeof schema.users.$inferSelect) => user.id); - const lastSessions = - userIds.length > 0 - ? await db - .select({ - odriUserId: schema.sessions.userId, - lastActivityAt: sql`MAX(${schema.sessions.lastActivityAt})`, - }) - .from(schema.sessions) - .where(sql`${schema.sessions.userId} IN (${sql.join(userIds, sql`, `)})`) - .groupBy(schema.sessions.userId) - : []; - - const sessionMap = new Map( - lastSessions.map((session) => [session.odriUserId, session.lastActivityAt]) - ); - - const userList: UserListItem[] = users.map((user: typeof schema.users.$inferSelect) => ({ - id: user.id, - email: user.email, - name: user.name, - role: user.role, - createdAt: user.createdAt.toISOString(), - lastActiveAt: sessionMap.get(user.id)?.toISOString(), - })); - - return { - users: userList, - total: countResult[0]?.count ?? 0, - page, - limit, - }; - } - - /** - * Get aggregated user data from all projects - */ - async getUserDataSummary(userId: string): Promise { - const db = this.getDatabase(); - this.logger.log(`Getting user data summary for userId: ${userId}`); - - // Get user data from local DB - const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1); - - if (!user.length) { - throw new NotFoundException(`User ${userId} not found`); - } - - // Get auth data - const [sessionsCount, accountsCount, has2FA, lastSession] = await Promise.all([ - db - .select({ count: sql`count(*)::int` }) - .from(schema.sessions) - .where(eq(schema.sessions.userId, userId)), - db - .select({ count: sql`count(*)::int` }) - .from(schema.accounts) - .where(eq(schema.accounts.userId, userId)), - db - .select() - .from(schema.twoFactorAuth) - .where(eq(schema.twoFactorAuth.userId, userId)) - .limit(1), - db - .select({ lastActivityAt: schema.sessions.lastActivityAt }) - .from(schema.sessions) - .where(eq(schema.sessions.userId, userId)) - .orderBy(desc(schema.sessions.lastActivityAt)) - .limit(1), - ]); - - // Get credits data - const creditsResult = await db - .select() - .from(schema.balances) - .where(eq(schema.balances.userId, userId)) - .limit(1); - - const transactionsCount = await db - .select({ count: sql`count(*)::int` }) - .from(schema.transactions) - .where(eq(schema.transactions.userId, userId)); - - const credits = creditsResult[0]; - - // Query all backends in parallel - const projectResults = await Promise.all( - this.projectConfigs.map((config) => this.queryBackend(config, userId)) - ); - - // Calculate totals - const totalEntities = projectResults.reduce( - (sum, p) => sum + (p.available ? p.totalCount : 0), - 0 - ); - const projectsWithData = projectResults.filter((p) => p.available && p.totalCount > 0).length; - - return { - user: { - id: user[0].id, - email: user[0].email, - name: user[0].name, - role: user[0].role, - createdAt: user[0].createdAt.toISOString(), - emailVerified: user[0].emailVerified, - }, - auth: { - sessionsCount: sessionsCount[0]?.count ?? 0, - accountsCount: accountsCount[0]?.count ?? 0, - has2FA: has2FA.length > 0, - lastLoginAt: lastSession[0]?.lastActivityAt?.toISOString() ?? null, - }, - credits: { - balance: credits?.balance ?? 0, - totalEarned: credits?.totalEarned ?? 0, - totalSpent: credits?.totalSpent ?? 0, - transactionsCount: transactionsCount[0]?.count ?? 0, - }, - projects: projectResults, - totals: { - totalEntities, - projectsWithData, - }, - }; - } - - /** - * Delete all user data across all projects (GDPR) - */ - async deleteUserData(userId: string): Promise { - const db = this.getDatabase(); - this.logger.log(`Deleting all user data for userId: ${userId}`); - - // Verify user exists - const user = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1); - - if (!user.length) { - throw new NotFoundException(`User ${userId} not found`); - } - - // Delete from all backends in parallel - const projectResults = await Promise.all( - this.projectConfigs.map(async (config) => { - try { - const response = await firstValueFrom( - this.httpService.delete(`${config.url}/api/v1/admin/user-data/${userId}`, { - headers: { 'X-Service-Key': this.serviceKey }, - timeout: 10000, - }) - ); - return { - projectId: config.id, - projectName: config.name, - success: true, - deletedCount: response.data?.totalDeleted ?? 0, - }; - } catch (error: any) { - this.logger.warn(`Failed to delete data from ${config.name}: ${error.message}`); - return { - projectId: config.id, - projectName: config.name, - success: false, - error: error.message, - }; - } - }) - ); - - // Delete from local auth tables - const [deletedSessions, deletedAccounts, deletedTransactions] = await Promise.all([ - db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)).returning(), - db.delete(schema.accounts).where(eq(schema.accounts.userId, userId)).returning(), - db.delete(schema.transactions).where(eq(schema.transactions.userId, userId)).returning(), - ]); - - // Delete credits balance - await db.delete(schema.balances).where(eq(schema.balances.userId, userId)); - - // Delete 2FA - await db.delete(schema.twoFactorAuth).where(eq(schema.twoFactorAuth.userId, userId)); - - // Soft delete user (or hard delete if preferred) - await db.update(schema.users).set({ deletedAt: new Date() }).where(eq(schema.users.id, userId)); - - const totalFromProjects = projectResults - .filter((p) => p.success) - .reduce((sum, p) => sum + (p.deletedCount ?? 0), 0); - - return { - success: true, - deletedFromProjects: projectResults, - deletedFromAuth: { - sessions: deletedSessions.length, - accounts: deletedAccounts.length, - credits: deletedTransactions.length, - user: true, - }, - totalDeleted: - totalFromProjects + - deletedSessions.length + - deletedAccounts.length + - deletedTransactions.length, - }; - } - - /** - * Get full export data including sessions, security events, and transactions - */ - async getFullExportData(userId: string) { - const summary = await this.getUserDataSummary(userId); - - // Get additional details for export - const [sessions, securityEvents, transactions] = await Promise.all([ - this.getSessionHistory(userId), - this.getSecurityEvents(userId), - this.getTransactionHistory(userId), - ]); - - return { - ...summary, - exportedAt: new Date().toISOString(), - exportVersion: '2.0', - sessions: { - active: sessions.filter((s) => !s.revokedAt && new Date(s.expiresAt) > new Date()), - history: sessions, - }, - securityEvents, - creditTransactions: transactions, - }; - } - - /** - * Get session history for a user - */ - private async getSessionHistory(userId: string) { - const db = this.getDatabase(); - - return db - .select({ - id: schema.sessions.id, - createdAt: schema.sessions.createdAt, - expiresAt: schema.sessions.expiresAt, - lastActivityAt: schema.sessions.lastActivityAt, - ipAddress: schema.sessions.ipAddress, - userAgent: schema.sessions.userAgent, - deviceName: schema.sessions.deviceName, - revokedAt: schema.sessions.revokedAt, - }) - .from(schema.sessions) - .where(eq(schema.sessions.userId, userId)) - .orderBy(desc(schema.sessions.createdAt)) - .limit(100); - } - - /** - * Get security events for a user - */ - private async getSecurityEvents(userId: string) { - const db = this.getDatabase(); - - return db - .select({ - id: schema.securityEvents.id, - eventType: schema.securityEvents.eventType, - ipAddress: schema.securityEvents.ipAddress, - userAgent: schema.securityEvents.userAgent, - metadata: schema.securityEvents.metadata, - createdAt: schema.securityEvents.createdAt, - }) - .from(schema.securityEvents) - .where(eq(schema.securityEvents.userId, userId)) - .orderBy(desc(schema.securityEvents.createdAt)) - .limit(100); - } - - /** - * Get transaction history for a user - */ - private async getTransactionHistory(userId: string) { - const db = this.getDatabase(); - - return db - .select({ - id: schema.transactions.id, - type: schema.transactions.type, - status: schema.transactions.status, - amount: schema.transactions.amount, - balanceBefore: schema.transactions.balanceBefore, - balanceAfter: schema.transactions.balanceAfter, - appId: schema.transactions.appId, - description: schema.transactions.description, - createdAt: schema.transactions.createdAt, - completedAt: schema.transactions.completedAt, - }) - .from(schema.transactions) - .where(eq(schema.transactions.userId, userId)) - .orderBy(desc(schema.transactions.createdAt)); - } - - /** - * Get user data for email (before deletion) - */ - async getUserForEmail(userId: string) { - const db = this.getDatabase(); - - const user = await db - .select({ - email: schema.users.email, - name: schema.users.name, - }) - .from(schema.users) - .where(eq(schema.users.id, userId)) - .limit(1); - - return user[0] || null; - } - - /** - * Query a single backend for user data - */ - private async queryBackend(config: ProjectConfig, userId: string): Promise { - try { - const response = await firstValueFrom( - this.httpService.get(`${config.url}/api/v1/admin/user-data/${userId}`, { - headers: { 'X-Service-Key': this.serviceKey }, - timeout: 5000, - }) - ); - - return { - projectId: config.id, - projectName: config.name, - icon: config.icon, - available: true, - entities: response.data.entities || [], - totalCount: response.data.totalCount || 0, - lastActivityAt: response.data.lastActivityAt, - }; - } catch (error: any) { - this.logger.warn(`Backend ${config.name} unavailable: ${error.message}`); - return { - projectId: config.id, - projectName: config.name, - icon: config.icon, - available: false, - error: error.code === 'ECONNREFUSED' ? 'Backend offline' : error.message, - entities: [], - totalCount: 0, - }; - } - } -} diff --git a/services/mana-core-auth/src/ai/ai.module.ts b/services/mana-core-auth/src/ai/ai.module.ts deleted file mode 100644 index 3b6d89254..000000000 --- a/services/mana-core-auth/src/ai/ai.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { AiService } from './ai.service'; - -@Global() -@Module({ - providers: [AiService], - exports: [AiService], -}) -export class AiModule {} diff --git a/services/mana-core-auth/src/ai/ai.service.ts b/services/mana-core-auth/src/ai/ai.service.ts deleted file mode 100644 index 5df822f5e..000000000 --- a/services/mana-core-auth/src/ai/ai.service.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { LlmClientService } from '@manacore/shared-llm'; - -export interface FeedbackAnalysis { - title: string; - category: 'bug' | 'feature' | 'improvement' | 'question' | 'other'; -} - -const VALID_CATEGORIES = ['bug', 'feature', 'improvement', 'question', 'other'] as const; - -@Injectable() -export class AiService { - private readonly logger = new Logger(AiService.name); - - constructor(private readonly llm: LlmClientService) {} - - async analyzeFeedback(feedbackText: string): Promise { - try { - const prompt = `Analysiere dieses User-Feedback und generiere: -1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst -2. Eine passende Kategorie aus: bug, feature, improvement, question, other - -Feedback: "${feedbackText}" - -Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text): -{"title": "...", "category": "..."}`; - - const { data } = await this.llm.json(prompt, { - temperature: 0.3, - timeout: 30_000, - validate: (raw) => { - const obj = raw as FeedbackAnalysis; - if (!obj.title || !obj.category) throw new Error('missing fields'); - if (!VALID_CATEGORIES.includes(obj.category as any)) { - obj.category = 'other'; - } - if (obj.title.length > 60) { - obj.title = obj.title.substring(0, 57) + '...'; - } - return obj; - }, - }); - - this.logger.debug(`AI analyzed feedback: ${JSON.stringify(data)}`); - return data; - } catch (error) { - this.logger.error(`AI analysis failed: ${error}`); - return this.fallbackAnalysis(feedbackText); - } - } - - private fallbackAnalysis(feedbackText: string): FeedbackAnalysis { - // Simple fallback: use first 60 chars as title, default category - const title = feedbackText.length > 60 ? feedbackText.substring(0, 57) + '...' : feedbackText; - - // Simple keyword-based category detection - const lowerText = feedbackText.toLowerCase(); - let category: FeedbackAnalysis['category'] = 'feature'; - - if ( - lowerText.includes('bug') || - lowerText.includes('fehler') || - lowerText.includes('kaputt') || - lowerText.includes('funktioniert nicht') - ) { - category = 'bug'; - } else if ( - lowerText.includes('?') || - lowerText.includes('frage') || - lowerText.includes('wie') - ) { - category = 'question'; - } else if ( - lowerText.includes('besser') || - lowerText.includes('verbessern') || - lowerText.includes('optimieren') - ) { - category = 'improvement'; - } - - return { title, category }; - } -} diff --git a/services/mana-core-auth/src/ai/index.ts b/services/mana-core-auth/src/ai/index.ts deleted file mode 100644 index 4252f0044..000000000 --- a/services/mana-core-auth/src/ai/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ai.module'; -export * from './ai.service'; diff --git a/services/mana-core-auth/src/analytics/analytics.controller.ts b/services/mana-core-auth/src/analytics/analytics.controller.ts deleted file mode 100644 index b2a02a197..000000000 --- a/services/mana-core-auth/src/analytics/analytics.controller.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Controller, Get, Post, Query, Res, HttpStatus } from '@nestjs/common'; -import { Response } from 'express'; -import { AnalyticsService } from './analytics.service'; - -@Controller('analytics') -export class AnalyticsController { - constructor(private readonly analyticsService: AnalyticsService) {} - - /** - * Health check endpoint - */ - @Get('health') - async getHealth() { - return this.analyticsService.getHealth(); - } - - /** - * Get latest metrics snapshot - */ - @Get('latest') - async getLatest() { - const metrics = await this.analyticsService.getLatestMetrics(); - if (!metrics) { - return { message: 'No metrics recorded yet' }; - } - return metrics; - } - - /** - * Get user growth data - * @param days Number of days to look back (default: 90) - */ - @Get('growth') - async getGrowth(@Query('days') days?: string) { - const numDays = days ? parseInt(days, 10) : 90; - return this.analyticsService.getUserGrowth(numDays); - } - - /** - * Get monthly aggregated metrics - * @param months Number of months to look back (default: 12) - */ - @Get('monthly') - async getMonthly(@Query('months') months?: string) { - const numMonths = months ? parseInt(months, 10) : 12; - return this.analyticsService.getMonthlyMetrics(numMonths); - } - - /** - * Get metrics for a date range - * @param start Start date (YYYY-MM-DD) - * @param end End date (YYYY-MM-DD) - */ - @Get('range') - async getRange(@Query('start') start: string, @Query('end') end: string) { - if (!start || !end) { - return { error: 'Both start and end dates are required (YYYY-MM-DD format)' }; - } - return this.analyticsService.getMetricsRange(start, end); - } - - /** - * Trigger manual snapshot (for testing/recovery) - */ - @Post('snapshot') - async triggerSnapshot() { - await this.analyticsService.recordDailySnapshot(); - return { message: 'Snapshot recorded successfully' }; - } - - /** - * Grafana JSON API compatible endpoint - query - * Used by Grafana Infinity datasource - */ - @Post('grafana/query') - async grafanaQuery(@Res() res: Response) { - // Return available targets - const latest = await this.analyticsService.getLatestMetrics(); - const growth = await this.analyticsService.getUserGrowth(30); - - res.status(HttpStatus.OK).json([ - { - target: 'total_users', - datapoints: growth.map((g) => [g.total_users, new Date(g.date).getTime()]), - }, - { - target: 'daily_growth', - datapoints: growth.map((g) => [g.growth ?? 0, new Date(g.date).getTime()]), - }, - ]); - } - - /** - * Grafana JSON API compatible endpoint - search - * Returns available metrics - */ - @Post('grafana/search') - async grafanaSearch() { - return [ - 'total_users', - 'verified_users', - 'new_users_today', - 'new_users_week', - 'new_users_month', - 'daily_growth', - ]; - } - - /** - * Summary endpoint for dashboards - */ - @Get('summary') - async getSummary() { - const latest = await this.analyticsService.getLatestMetrics(); - const monthly = await this.analyticsService.getMonthlyMetrics(2); - const health = await this.analyticsService.getHealth(); - - const currentMonth = monthly[monthly.length - 1]; - const previousMonth = monthly[monthly.length - 2]; - - return { - current: latest, - trends: { - month_over_month_growth: - currentMonth && previousMonth - ? ((currentMonth.total_users_eom - previousMonth.total_users_eom) / - previousMonth.total_users_eom) * - 100 - : null, - new_users_this_month: currentMonth?.new_users ?? 0, - }, - health, - }; - } -} diff --git a/services/mana-core-auth/src/analytics/analytics.module.ts b/services/mana-core-auth/src/analytics/analytics.module.ts deleted file mode 100644 index a8e27f641..000000000 --- a/services/mana-core-auth/src/analytics/analytics.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; -import { AnalyticsService } from './analytics.service'; -import { AnalyticsController } from './analytics.controller'; - -@Module({ - imports: [ScheduleModule.forRoot()], - controllers: [AnalyticsController], - providers: [AnalyticsService], - exports: [AnalyticsService], -}) -export class AnalyticsModule {} diff --git a/services/mana-core-auth/src/analytics/analytics.service.ts b/services/mana-core-auth/src/analytics/analytics.service.ts deleted file mode 100644 index b47ffe100..000000000 --- a/services/mana-core-auth/src/analytics/analytics.service.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { Database } from 'duckdb-async'; -import { sql } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import * as fs from 'fs'; -import * as path from 'path'; - -export interface DailyMetrics { - date: string; - total_users: number; - verified_users: number; - new_users_today: number; - new_users_week: number; - new_users_month: number; - total_db_size_bytes: number | null; - recorded_at: string; -} - -export interface GrowthData { - date: string; - total_users: number; - growth: number | null; - growth_percent: number | null; -} - -export interface MonthlyMetrics { - month: string; - total_users_eom: number; - new_users: number; - growth_percent: number | null; -} - -@Injectable() -export class AnalyticsService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(AnalyticsService.name); - private duckdb: Database | null = null; - private readonly dbPath: string; - private readonly databaseUrl: string; - - constructor(private readonly configService: ConfigService) { - this.dbPath = this.configService.get('DUCKDB_PATH', './data/metrics.duckdb'); - this.databaseUrl = this.configService.get('DATABASE_URL', ''); - } - - async onModuleInit(): Promise { - try { - // Ensure the directory exists - const dbDir = path.dirname(this.dbPath); - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); - this.logger.log(`Created DuckDB directory: ${dbDir}`); - } - - this.duckdb = await Database.create(this.dbPath); - await this.initializeSchema(); - this.logger.log(`DuckDB initialized at ${this.dbPath}`); - - // Record initial snapshot if database is empty - const count = await this.getRecordCount(); - if (count === 0) { - this.logger.log('No existing records found, recording initial snapshot...'); - await this.recordDailySnapshot(); - } - } catch (error) { - this.logger.error('Failed to initialize DuckDB', error); - } - } - - async onModuleDestroy(): Promise { - if (this.duckdb) { - await this.duckdb.close(); - this.logger.log('DuckDB connection closed'); - } - } - - private async initializeSchema(): Promise { - if (!this.duckdb) return; - - await this.duckdb.run(` - CREATE TABLE IF NOT EXISTS daily_metrics ( - date DATE PRIMARY KEY, - total_users INTEGER NOT NULL, - verified_users INTEGER NOT NULL, - new_users_today INTEGER NOT NULL, - new_users_week INTEGER NOT NULL, - new_users_month INTEGER NOT NULL, - total_db_size_bytes BIGINT, - recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - `); - - this.logger.log('DuckDB schema initialized'); - } - - private async getRecordCount(): Promise { - if (!this.duckdb) return 0; - const result = await this.duckdb.all('SELECT COUNT(*) as count FROM daily_metrics'); - return Number(result[0]?.count ?? 0); - } - - /** - * Record daily snapshot - runs at midnight UTC - */ - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async recordDailySnapshot(): Promise { - if (!this.duckdb) { - this.logger.warn('DuckDB not initialized, skipping snapshot'); - return; - } - - try { - const today = new Date().toISOString().split('T')[0]; - - // Get user counts from PostgreSQL - const [totalUsers, verifiedUsers, newToday, newWeek, newMonth, dbSize] = await Promise.all([ - this.countTotalUsers(), - this.countVerifiedUsers(), - this.countUsersCreatedSince(1), - this.countUsersCreatedSince(7), - this.countUsersCreatedSince(30), - this.getDatabaseSize(), - ]); - - // Insert or replace in DuckDB - await this.duckdb.run( - ` - INSERT OR REPLACE INTO daily_metrics - (date, total_users, verified_users, new_users_today, new_users_week, new_users_month, total_db_size_bytes, recorded_at) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `, - today, - totalUsers, - verifiedUsers, - newToday, - newWeek, - newMonth, - dbSize - ); - - this.logger.log(`Daily snapshot recorded for ${today}: ${totalUsers} total users`); - } catch (error) { - this.logger.error('Failed to record daily snapshot', error); - } - } - - /** - * Get user growth over time - */ - async getUserGrowth(days = 90): Promise { - if (!this.duckdb) return []; - - const result = await this.duckdb.all( - ` - SELECT - date::VARCHAR as date, - total_users, - total_users - LAG(total_users) OVER (ORDER BY date) as growth, - ROUND(((total_users::FLOAT - LAG(total_users) OVER (ORDER BY date)) / - NULLIF(LAG(total_users) OVER (ORDER BY date), 0)) * 100, 2) as growth_percent - FROM daily_metrics - WHERE date > CURRENT_DATE - INTERVAL '${days} days' - ORDER BY date - ` - ); - - return result as GrowthData[]; - } - - /** - * Get monthly aggregated metrics - */ - async getMonthlyMetrics(months = 12): Promise { - if (!this.duckdb) return []; - - const result = await this.duckdb.all( - ` - SELECT - strftime(date_trunc('month', date), '%Y-%m') as month, - MAX(total_users)::INTEGER as total_users_eom, - SUM(new_users_today)::INTEGER as new_users, - ROUND(((MAX(total_users)::FLOAT - MIN(total_users)) / - NULLIF(MIN(total_users), 0)) * 100, 2) as growth_percent - FROM daily_metrics - WHERE date > CURRENT_DATE - INTERVAL '${months} months' - GROUP BY date_trunc('month', date) - ORDER BY month - ` - ); - - return result as MonthlyMetrics[]; - } - - /** - * Get latest metrics - */ - async getLatestMetrics(): Promise { - if (!this.duckdb) return null; - - const result = await this.duckdb.all(` - SELECT - date::VARCHAR as date, - total_users, - verified_users, - new_users_today, - new_users_week, - new_users_month, - total_db_size_bytes::INTEGER as total_db_size_bytes, - recorded_at::VARCHAR as recorded_at - FROM daily_metrics - ORDER BY date DESC - LIMIT 1 - `); - - return (result[0] as DailyMetrics) ?? null; - } - - /** - * Get all metrics for a date range - */ - async getMetricsRange(startDate: string, endDate: string): Promise { - if (!this.duckdb) return []; - - const result = await this.duckdb.all( - ` - SELECT - date::VARCHAR as date, - total_users, - verified_users, - new_users_today, - new_users_week, - new_users_month, - total_db_size_bytes::INTEGER as total_db_size_bytes, - recorded_at::VARCHAR as recorded_at - FROM daily_metrics - WHERE date BETWEEN ? AND ? - ORDER BY date - `, - startDate, - endDate - ); - - return result as DailyMetrics[]; - } - - /** - * Health check for the analytics service - */ - async getHealth(): Promise<{ - status: string; - database_path: string; - database_size_bytes: number | null; - total_records: number; - latest_snapshot: string | null; - }> { - const recordCount = await this.getRecordCount(); - const latest = await this.getLatestMetrics(); - - return { - status: this.duckdb ? 'healthy' : 'unhealthy', - database_path: this.dbPath, - database_size_bytes: null, // DuckDB doesn't expose this easily - total_records: recordCount, - latest_snapshot: latest?.date ?? null, - }; - } - - /** - * Export metrics to Parquet format (for archival) - */ - async exportToParquet(outputPath: string): Promise { - if (!this.duckdb) { - throw new Error('DuckDB not initialized'); - } - - await this.duckdb.run(`COPY daily_metrics TO '${outputPath}' (FORMAT PARQUET)`); - this.logger.log(`Metrics exported to ${outputPath}`); - } - - // ============================================ - // PostgreSQL Query Helpers - // ============================================ - - private getPostgresDb() { - if (!this.databaseUrl) { - throw new Error('DATABASE_URL not configured'); - } - return getDb(this.databaseUrl); - } - - private async countTotalUsers(): Promise { - const db = this.getPostgresDb(); - const result = await db.execute(sql`SELECT COUNT(*) as count FROM auth.users`); - const row = result[0] as { count: string | number } | undefined; - return Number(row?.count ?? 0); - } - - private async countVerifiedUsers(): Promise { - const db = this.getPostgresDb(); - const result = await db.execute( - sql`SELECT COUNT(*) as count FROM auth.users WHERE email_verified = true` - ); - const row = result[0] as { count: string | number } | undefined; - return Number(row?.count ?? 0); - } - - private async countUsersCreatedSince(days: number): Promise { - const db = this.getPostgresDb(); - const result = await db.execute( - sql`SELECT COUNT(*) as count FROM auth.users WHERE created_at > NOW() - INTERVAL '${sql.raw(days.toString())} days'` - ); - const row = result[0] as { count: string | number } | undefined; - return Number(row?.count ?? 0); - } - - private async getDatabaseSize(): Promise { - try { - const db = this.getPostgresDb(); - const result = await db.execute(sql`SELECT pg_database_size(current_database()) as size`); - const row = result[0] as { size: string | number } | undefined; - return Number(row?.size ?? 0); - } catch { - return null; - } - } -} diff --git a/services/mana-core-auth/src/analytics/index.ts b/services/mana-core-auth/src/analytics/index.ts deleted file mode 100644 index 456948c98..000000000 --- a/services/mana-core-auth/src/analytics/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './analytics.module'; -export * from './analytics.service'; -export * from './analytics.controller'; diff --git a/services/mana-core-auth/src/api-keys/api-keys.controller.ts b/services/mana-core-auth/src/api-keys/api-keys.controller.ts deleted file mode 100644 index 8d41f9713..000000000 --- a/services/mana-core-auth/src/api-keys/api-keys.controller.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - Req, - UseGuards, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import type { Request } from 'express'; -import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; -import { ApiKeysService } from './api-keys.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { CreateApiKeyDto, ValidateApiKeyDto } from './dto'; -import { SecurityEventsService, SecurityEventType } from '../security'; - -@Controller('api-keys') -export class ApiKeysController { - constructor( - private readonly apiKeysService: ApiKeysService, - private readonly securityEvents: SecurityEventsService - ) {} - - /** - * List all API keys for the authenticated user - */ - @Get() - @UseGuards(JwtAuthGuard) - async listKeys(@CurrentUser() user: CurrentUserData) { - return this.apiKeysService.listUserApiKeys(user.userId); - } - - /** - * Create a new API key - * Returns the full key only once - it cannot be retrieved later - */ - @Post() - @UseGuards(JwtAuthGuard) - async createKey( - @CurrentUser() user: CurrentUserData, - @Body() dto: CreateApiKeyDto, - @Req() req: Request - ) { - const result = await this.apiKeysService.createApiKey(user.userId, dto); - - this.securityEvents.logEventWithRequest(req, { - userId: user.userId, - eventType: SecurityEventType.API_KEY_CREATED, - metadata: { keyId: result.id, name: dto.name, scopes: dto.scopes }, - }); - - return result; - } - - /** - * Revoke an API key - */ - @Delete(':id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.NO_CONTENT) - async revokeKey( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Req() req: Request - ) { - await this.apiKeysService.revokeApiKey(user.userId, id); - - this.securityEvents.logEventWithRequest(req, { - userId: user.userId, - eventType: SecurityEventType.API_KEY_REVOKED, - metadata: { keyId: id }, - }); - } - - /** - * Validate an API key (for internal services like STT/TTS) - * - * This endpoint does NOT require JWT authentication since it's called - * by services that only have an API key, not a JWT. - * - * Rate limited to 10 requests/minute per IP to prevent brute force. - */ - @Post('validate') - @UseGuards(ThrottlerGuard) - @Throttle({ default: { ttl: 60000, limit: 10 } }) - @HttpCode(HttpStatus.OK) - async validateKey(@Body() dto: ValidateApiKeyDto, @Req() req: Request) { - const result = await this.apiKeysService.validateApiKey(dto.apiKey, dto.scope); - - const eventType = result.valid - ? SecurityEventType.API_KEY_VALIDATED - : SecurityEventType.API_KEY_VALIDATION_FAILED; - - this.securityEvents.logEventWithRequest(req, { - userId: result.valid ? result.userId : undefined, - eventType, - metadata: { - scope: dto.scope, - keyPrefix: dto.apiKey?.substring(0, 16) + '...', - }, - }); - - return result; - } -} diff --git a/services/mana-core-auth/src/api-keys/api-keys.module.ts b/services/mana-core-auth/src/api-keys/api-keys.module.ts deleted file mode 100644 index f2bc65400..000000000 --- a/services/mana-core-auth/src/api-keys/api-keys.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ApiKeysController } from './api-keys.controller'; -import { ApiKeysService } from './api-keys.service'; -import { SecurityModule } from '../security'; - -@Module({ - imports: [SecurityModule], - controllers: [ApiKeysController], - providers: [ApiKeysService], - exports: [ApiKeysService], -}) -export class ApiKeysModule {} diff --git a/services/mana-core-auth/src/api-keys/api-keys.service.ts b/services/mana-core-auth/src/api-keys/api-keys.service.ts deleted file mode 100644 index 3836ca6f0..000000000 --- a/services/mana-core-auth/src/api-keys/api-keys.service.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, isNull } from 'drizzle-orm'; -import { createHash, randomBytes } from 'crypto'; -import { nanoid } from 'nanoid'; -import { getDb } from '../db/connection'; -import { apiKeys } from '../db/schema'; -import { CreateApiKeyDto } from './dto/create-api-key.dto'; -import type { ValidateApiKeyResponseDto } from './dto/validate-api-key.dto'; - -const DEFAULT_SCOPES = ['stt', 'tts']; -const KEY_PREFIX = 'sk_live_'; - -@Injectable() -export class ApiKeysService { - constructor(private configService: ConfigService) {} - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - /** - * Generate a new API key - * Format: sk_live_<32 random hex chars> - */ - private generateKey(): string { - const randomPart = randomBytes(16).toString('hex'); - return `${KEY_PREFIX}${randomPart}`; - } - - /** - * Hash an API key using SHA-256 - */ - private hashKey(key: string): string { - return createHash('sha256').update(key).digest('hex'); - } - - /** - * Extract prefix for display (first 12 characters after sk_live_) - */ - private getKeyPrefix(key: string): string { - return key.substring(0, KEY_PREFIX.length + 8) + '...'; - } - - /** - * List all API keys for a user (without exposing the full key) - */ - async listUserApiKeys(userId: string) { - const db = this.getDb(); - const keys = await db - .select({ - id: apiKeys.id, - name: apiKeys.name, - keyPrefix: apiKeys.keyPrefix, - scopes: apiKeys.scopes, - rateLimitRequests: apiKeys.rateLimitRequests, - rateLimitWindow: apiKeys.rateLimitWindow, - createdAt: apiKeys.createdAt, - lastUsedAt: apiKeys.lastUsedAt, - revokedAt: apiKeys.revokedAt, - }) - .from(apiKeys) - .where(eq(apiKeys.userId, userId)); - - return keys; - } - - /** - * Create a new API key - * Returns the full key only once - it cannot be retrieved later - */ - async createApiKey(userId: string, dto: CreateApiKeyDto) { - const db = this.getDb(); - - const key = this.generateKey(); - const keyHash = this.hashKey(key); - const keyPrefix = this.getKeyPrefix(key); - - const [apiKey] = await db - .insert(apiKeys) - .values({ - id: nanoid(), - userId, - name: dto.name, - keyPrefix, - keyHash, - scopes: dto.scopes || DEFAULT_SCOPES, - rateLimitRequests: dto.rateLimitRequests || 60, - rateLimitWindow: dto.rateLimitWindow || 60, - }) - .returning(); - - // Return the full key only on creation - return { - id: apiKey.id, - name: apiKey.name, - key, // Full key - shown only once - keyPrefix: apiKey.keyPrefix, - scopes: apiKey.scopes, - rateLimitRequests: apiKey.rateLimitRequests, - rateLimitWindow: apiKey.rateLimitWindow, - createdAt: apiKey.createdAt, - }; - } - - /** - * Revoke an API key (soft delete) - */ - async revokeApiKey(userId: string, keyId: string) { - const db = this.getDb(); - - // Verify key exists and belongs to user - const [existing] = await db - .select() - .from(apiKeys) - .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt))) - .limit(1); - - if (!existing) { - throw new NotFoundException('API key not found'); - } - - await db - .update(apiKeys) - .set({ revokedAt: new Date() }) - .where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, userId))); - } - - /** - * Validate an API key (for STT/TTS services to call) - * This endpoint does NOT require authentication - */ - async validateApiKey(apiKey: string, scope?: string): Promise { - const db = this.getDb(); - - // Hash the incoming key to compare - const keyHash = this.hashKey(apiKey); - - // Find the key - const [key] = await db - .select() - .from(apiKeys) - .where(and(eq(apiKeys.keyHash, keyHash), isNull(apiKeys.revokedAt))) - .limit(1); - - if (!key) { - return { valid: false, error: 'Invalid or revoked API key' }; - } - - // Check scope if provided - if (scope && key.scopes && !key.scopes.includes(scope)) { - return { valid: false, error: `API key does not have scope: ${scope}` }; - } - - // Update last used timestamp (fire-and-forget) - db.update(apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKeys.id, key.id)) - .then(() => {}) - .catch(() => {}); - - return { - valid: true, - userId: key.userId, - scopes: key.scopes || [], - rateLimit: { - requests: key.rateLimitRequests, - window: key.rateLimitWindow, - }, - }; - } -} diff --git a/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts b/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts deleted file mode 100644 index c93a80301..000000000 --- a/services/mana-core-auth/src/api-keys/dto/create-api-key.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsString, IsOptional, MaxLength, IsArray, IsInt, Min, Max } from 'class-validator'; - -export class CreateApiKeyDto { - @IsString() - @MaxLength(100) - name: string; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - scopes?: string[]; - - @IsOptional() - @IsInt() - @Min(1) - @Max(1000) - rateLimitRequests?: number; - - @IsOptional() - @IsInt() - @Min(1) - @Max(3600) - rateLimitWindow?: number; -} diff --git a/services/mana-core-auth/src/api-keys/dto/index.ts b/services/mana-core-auth/src/api-keys/dto/index.ts deleted file mode 100644 index dc7bdafdf..000000000 --- a/services/mana-core-auth/src/api-keys/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-api-key.dto'; -export * from './validate-api-key.dto'; diff --git a/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts b/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts deleted file mode 100644 index e8269d2ac..000000000 --- a/services/mana-core-auth/src/api-keys/dto/validate-api-key.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsString, IsOptional } from 'class-validator'; - -export class ValidateApiKeyDto { - @IsString() - apiKey: string; - - @IsOptional() - @IsString() - scope?: string; -} - -export class ValidateApiKeyResponseDto { - valid: boolean; - userId?: string; - scopes?: string[]; - rateLimit?: { - requests: number; - window: number; - }; - error?: string; -} diff --git a/services/mana-core-auth/src/api-keys/index.ts b/services/mana-core-auth/src/api-keys/index.ts deleted file mode 100644 index 67401a60d..000000000 --- a/services/mana-core-auth/src/api-keys/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './api-keys.module'; -export * from './api-keys.service'; -export * from './api-keys.controller'; -export * from './dto'; diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts deleted file mode 100644 index 739137f71..000000000 --- a/services/mana-core-auth/src/app.module.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { ThrottlerModule } from '@nestjs/throttler'; -import { APP_FILTER } from '@nestjs/core'; -import { LlmModule } from '@manacore/shared-llm'; -import configuration from './config/configuration'; -import { AdminModule } from './admin/admin.module'; -import { ApiKeysModule } from './api-keys/api-keys.module'; -import { AuthModule } from './auth/auth.module'; -import { GuildsModule } from './guilds/guilds.module'; -import { HealthModule } from './health/health.module'; -import { MeModule } from './me/me.module'; -import { MetricsModule } from './metrics'; -import { HttpExceptionFilter } from './common/filters/http-exception.filter'; -import { LoggerModule } from './common/logger'; -import { SecurityModule } from './security'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - load: [configuration], - }), - ThrottlerModule.forRoot([ - { - ttl: 60000, // 60 seconds - limit: 100, // 100 requests per minute - }, - ]), - LlmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - manaLlmUrl: config.get('MANA_LLM_URL'), - debug: config.get('NODE_ENV') === 'development', - }), - inject: [ConfigService], - }), - LoggerModule, - SecurityModule, - MetricsModule, - AdminModule, - ApiKeysModule, - AuthModule, - GuildsModule, - HealthModule, - MeModule, - ], - providers: [ - { - provide: APP_FILTER, - useClass: HttpExceptionFilter, - }, - ], -}) -export class AppModule {} diff --git a/services/mana-core-auth/src/auth/auth.controller.spec.ts b/services/mana-core-auth/src/auth/auth.controller.spec.ts deleted file mode 100644 index 431fb695e..000000000 --- a/services/mana-core-auth/src/auth/auth.controller.spec.ts +++ /dev/null @@ -1,738 +0,0 @@ -/** - * AuthController Unit Tests - * - * Tests all authentication controller endpoints using BetterAuthService: - * - * B2C Endpoints: - * - POST /auth/register - User registration - * - POST /auth/login - User login - * - POST /auth/logout - User logout - * - POST /auth/refresh - Token refresh - * - GET /auth/session - Get current session - * - POST /auth/validate - Token validation - * - * B2B Endpoints: - * - POST /auth/register/b2b - Organization registration - * - GET /auth/organizations - List organizations - * - GET /auth/organizations/:id - Get organization - * - GET /auth/organizations/:id/members - Get organization members - * - POST /auth/organizations/:id/invite - Invite employee - * - POST /auth/organizations/accept-invitation - Accept invitation - * - DELETE /auth/organizations/:id/members/:memberId - Remove member - * - POST /auth/organizations/set-active - Set active organization - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { - UnauthorizedException, - ConflictException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { AuthController } from './auth.controller'; -import { BetterAuthService } from './services/better-auth.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { SecurityEventsService, AccountLockoutService } from '../security'; -import { mockDtoFactory } from '../__tests__/utils/mock-factories'; - -describe('AuthController', () => { - let controller: AuthController; - let betterAuthService: jest.Mocked; - - // Common test data - const mockAuthHeader = 'Bearer valid-jwt-token'; - const mockToken = 'valid-jwt-token'; - const mockReq = { headers: { 'user-agent': 'test' }, ip: '127.0.0.1' } as any; - - beforeEach(async () => { - // Create mock BetterAuthService with all methods - const mockBetterAuthService = { - registerB2C: jest.fn(), - registerB2B: jest.fn(), - signIn: jest.fn(), - signOut: jest.fn(), - getSession: jest.fn(), - listOrganizations: jest.fn(), - getOrganization: jest.fn(), - getOrganizationMembers: jest.fn(), - inviteEmployee: jest.fn(), - acceptInvitation: jest.fn(), - removeMember: jest.fn(), - setActiveOrganization: jest.fn(), - refreshToken: jest.fn(), - validateToken: jest.fn(), - }; - - const mockSecurityEventsService = { - logEvent: jest.fn().mockResolvedValue(undefined), - logEventWithRequest: jest.fn().mockResolvedValue(undefined), - extractRequestInfo: jest.fn().mockReturnValue({ ipAddress: '127.0.0.1', userAgent: 'test' }), - }; - - const mockAccountLockoutService = { - checkLockout: jest.fn().mockResolvedValue({ locked: false }), - recordAttempt: jest.fn().mockResolvedValue(undefined), - clearAttempts: jest.fn().mockResolvedValue(undefined), - }; - - const module: TestingModule = await Test.createTestingModule({ - imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], - controllers: [AuthController], - providers: [ - { - provide: BetterAuthService, - useValue: mockBetterAuthService, - }, - { - provide: SecurityEventsService, - useValue: mockSecurityEventsService, - }, - { - provide: AccountLockoutService, - useValue: mockAccountLockoutService, - }, - ], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .overrideGuard(ThrottlerGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .compile(); - - controller = module.get(AuthController); - betterAuthService = module.get(BetterAuthService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ============================================================================ - // POST /auth/register (B2C) - // ============================================================================ - - describe('POST /auth/register', () => { - it('should successfully register a new B2C user', async () => { - const registerDto = mockDtoFactory.register({ - email: 'newuser@example.com', - password: 'SecurePassword123!', - name: 'New User', - }); - - const expectedResult = { - user: { - id: 'user-123', - email: registerDto.email, - name: registerDto.name, - }, - token: 'jwt-token', - }; - - betterAuthService.registerB2C.mockResolvedValue(expectedResult); - - const result = await controller.register(registerDto, mockReq); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ - email: registerDto.email, - password: registerDto.password, - name: registerDto.name, - }); - }); - - it('should handle registration without name', async () => { - const registerDto = { - email: 'noname@example.com', - password: 'SecurePassword123!', - }; - - const expectedResult = { - user: { id: 'user-456', email: registerDto.email, name: '' }, - token: 'jwt-token', - }; - - betterAuthService.registerB2C.mockResolvedValue(expectedResult); - - const result = await controller.register(registerDto as any, mockReq); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.registerB2C).toHaveBeenCalledWith({ - email: registerDto.email, - password: registerDto.password, - name: '', // Controller passes empty string as fallback when name is not provided - sourceAppUrl: undefined, - }); - }); - - it('should propagate ConflictException when user exists', async () => { - const registerDto = mockDtoFactory.register({ email: 'existing@example.com' }); - - betterAuthService.registerB2C.mockRejectedValue( - new ConflictException('User with this email already exists') - ); - - await expect(controller.register(registerDto, mockReq)).rejects.toThrow(ConflictException); - }); - }); - - // ============================================================================ - // POST /auth/login - // ============================================================================ - - describe('POST /auth/login', () => { - it('should successfully login a user', async () => { - const loginDto = mockDtoFactory.login({ - email: 'user@example.com', - password: 'SecurePassword123!', - }); - - const expectedResult = { - user: { - id: 'user-123', - email: loginDto.email, - name: 'Test User', - role: 'user', - }, - accessToken: 'jwt-access-token', - refreshToken: 'session-refresh-token', - expiresIn: 900, - }; - - betterAuthService.signIn.mockResolvedValue(expectedResult); - - const result = await controller.login(loginDto, mockReq); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.signIn).toHaveBeenCalledWith({ - email: loginDto.email, - password: loginDto.password, - deviceId: undefined, - deviceName: undefined, - }); - }); - - it('should pass device info when provided', async () => { - const loginDto = { - email: 'user@example.com', - password: 'SecurePassword123!', - deviceId: 'device-abc-123', - deviceName: 'iPhone 15 Pro', - }; - - betterAuthService.signIn.mockResolvedValue({ - user: { id: '123', email: 'user@example.com', name: 'Test', role: 'user' }, - accessToken: 'jwt-token', - refreshToken: 'refresh-token', - expiresIn: 900, - }); - - await controller.login(loginDto, mockReq); - - expect(betterAuthService.signIn).toHaveBeenCalledWith({ - email: loginDto.email, - password: loginDto.password, - deviceId: 'device-abc-123', - deviceName: 'iPhone 15 Pro', - }); - }); - - it('should propagate UnauthorizedException for invalid credentials', async () => { - const loginDto = mockDtoFactory.login({ password: 'WrongPassword' }); - - betterAuthService.signIn.mockRejectedValue( - new UnauthorizedException('Invalid email or password') - ); - - await expect(controller.login(loginDto, mockReq)).rejects.toThrow(UnauthorizedException); - }); - }); - - // ============================================================================ - // POST /auth/logout - // ============================================================================ - - describe('POST /auth/logout', () => { - it('should successfully logout a user', async () => { - const expectedResult = { success: true, message: 'Signed out successfully' }; - - betterAuthService.signOut.mockResolvedValue(expectedResult); - - const result = await controller.logout(mockAuthHeader, mockReq); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken); - }); - - it('should extract token from Bearer header', async () => { - betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); - - await controller.logout('Bearer my-secret-token', mockReq); - - expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token'); - }); - - it('should handle raw token without Bearer prefix', async () => { - betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' }); - - await controller.logout('raw-token', mockReq); - - expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token'); - }); - }); - - // ============================================================================ - // POST /auth/refresh - // ============================================================================ - - describe('POST /auth/refresh', () => { - it('should successfully refresh tokens', async () => { - const refreshTokenDto = { refreshToken: 'valid-refresh-token' }; - - const expectedResult = { - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - expiresIn: 900, - tokenType: 'Bearer', - user: { id: 'user-123', email: 'user@example.com', name: 'Test', role: 'user' as const }, - }; - - betterAuthService.refreshToken.mockResolvedValue(expectedResult); - - const result = await controller.refresh(refreshTokenDto); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.refreshToken).toHaveBeenCalledWith('valid-refresh-token'); - }); - - it('should propagate UnauthorizedException for invalid refresh token', async () => { - const refreshTokenDto = { refreshToken: 'invalid-token' }; - - betterAuthService.refreshToken.mockRejectedValue( - new UnauthorizedException('Invalid refresh token') - ); - - await expect(controller.refresh(refreshTokenDto)).rejects.toThrow(UnauthorizedException); - }); - }); - - // ============================================================================ - // GET /auth/session - // ============================================================================ - - describe('GET /auth/session', () => { - it('should return current session', async () => { - const expectedResult = { - user: { id: 'user-123', email: 'user@example.com', name: 'Test' }, - session: { id: 'session-123', activeOrganizationId: null }, - }; - - betterAuthService.getSession.mockResolvedValue(expectedResult as any); - - const result = await controller.getSession(mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.getSession).toHaveBeenCalledWith(mockToken); - }); - - it('should propagate UnauthorizedException for invalid session', async () => { - betterAuthService.getSession.mockRejectedValue( - new UnauthorizedException('Invalid or expired session') - ); - - await expect(controller.getSession(mockAuthHeader)).rejects.toThrow(UnauthorizedException); - }); - }); - - // ============================================================================ - // POST /auth/validate - // ============================================================================ - - describe('POST /auth/validate', () => { - it('should return valid for a valid token', async () => { - const body = { token: 'valid-jwt-token' }; - - const expectedResult = { - valid: true, - payload: { sub: 'user-123', email: 'user@example.com', role: 'user' }, - }; - - betterAuthService.validateToken.mockResolvedValue(expectedResult as any); - - const result = await controller.validate(body); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.validateToken).toHaveBeenCalledWith(body.token); - }); - - it('should return invalid for expired token', async () => { - const body = { token: 'expired-token' }; - - betterAuthService.validateToken.mockResolvedValue({ - valid: false, - error: 'Token expired', - } as any); - - const result = await controller.validate(body); - - expect((result as any).valid).toBe(false); - }); - }); - - // ============================================================================ - // POST /auth/register/b2b - // ============================================================================ - - describe('POST /auth/register/b2b', () => { - it('should successfully register a B2B organization', async () => { - const registerDto = { - ownerEmail: 'owner@acme.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const expectedResult = { - user: { id: 'user-123', email: registerDto.ownerEmail, name: registerDto.ownerName }, - organization: { id: 'org-456', name: 'Acme Corporation', slug: 'acme-corporation' }, - token: 'jwt-token', - }; - - betterAuthService.registerB2B.mockResolvedValue(expectedResult as any); - - const result = await controller.registerB2B(registerDto); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.registerB2B).toHaveBeenCalledWith(registerDto); - }); - - it('should propagate ConflictException when owner email exists', async () => { - const registerDto = { - ownerEmail: 'existing@acme.com', - password: 'SecurePassword123!', - ownerName: 'John', - organizationName: 'Acme', - }; - - betterAuthService.registerB2B.mockRejectedValue( - new ConflictException('Owner email already exists') - ); - - await expect(controller.registerB2B(registerDto)).rejects.toThrow(ConflictException); - }); - }); - - // ============================================================================ - // GET /auth/organizations - // ============================================================================ - - describe('GET /auth/organizations', () => { - it('should list user organizations', async () => { - const expectedResult = { - organizations: [ - { id: 'org-1', name: 'Org One', slug: 'org-one' }, - { id: 'org-2', name: 'Org Two', slug: 'org-two' }, - ], - }; - - betterAuthService.listOrganizations.mockResolvedValue(expectedResult as any); - - const result = await controller.listOrganizations(mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.listOrganizations).toHaveBeenCalledWith(mockToken); - }); - - it('should return empty array when user has no organizations', async () => { - betterAuthService.listOrganizations.mockResolvedValue({ organizations: [] }); - - const result = await controller.listOrganizations(mockAuthHeader); - - expect(result.organizations).toEqual([]); - }); - }); - - // ============================================================================ - // GET /auth/organizations/:id - // ============================================================================ - - describe('GET /auth/organizations/:id', () => { - it('should get organization details', async () => { - const orgId = 'org-123'; - const expectedResult = { - id: orgId, - name: 'Acme Corp', - slug: 'acme-corp', - members: [{ id: 'member-1', userId: 'user-1', role: 'owner' }], - }; - - betterAuthService.getOrganization.mockResolvedValue(expectedResult as any); - - const result = await controller.getOrganization(orgId, mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.getOrganization).toHaveBeenCalledWith(orgId, mockToken); - }); - - it('should throw NotFoundException when organization not found', async () => { - betterAuthService.getOrganization.mockRejectedValue( - new NotFoundException('Organization not found') - ); - - await expect(controller.getOrganization('invalid-id', mockAuthHeader)).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // GET /auth/organizations/:id/members - // ============================================================================ - - describe('GET /auth/organizations/:id/members', () => { - it('should get organization members', async () => { - const orgId = 'org-123'; - const expectedMembers = [ - { id: 'member-1', userId: 'user-1', organizationId: orgId, role: 'owner' }, - { id: 'member-2', userId: 'user-2', organizationId: orgId, role: 'member' }, - ]; - - betterAuthService.getOrganizationMembers.mockResolvedValue(expectedMembers as any); - - const result = await controller.getOrganizationMembers(orgId); - - expect(result).toEqual(expectedMembers); - expect(betterAuthService.getOrganizationMembers).toHaveBeenCalledWith(orgId); - }); - }); - - // ============================================================================ - // POST /auth/organizations/:id/invite - // ============================================================================ - - describe('POST /auth/organizations/:id/invite', () => { - it('should invite an employee to organization', async () => { - const orgId = 'org-123'; - const inviteDto = { - organizationId: orgId, - employeeEmail: 'employee@acme.com', - role: 'member' as const, - }; - - const expectedResult = { - id: 'invitation-123', - email: 'employee@acme.com', - organizationId: orgId, - role: 'member', - status: 'pending', - }; - - betterAuthService.inviteEmployee.mockResolvedValue(expectedResult as any); - - const result = await controller.inviteEmployee(orgId, inviteDto, mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.inviteEmployee).toHaveBeenCalledWith({ - organizationId: orgId, - employeeEmail: 'employee@acme.com', - role: 'member', - inviterToken: mockToken, - }); - }); - - it('should throw ForbiddenException when inviter lacks permission', async () => { - const orgId = 'org-123'; - const inviteDto = { - organizationId: orgId, - employeeEmail: 'employee@acme.com', - role: 'member' as const, - }; - - betterAuthService.inviteEmployee.mockRejectedValue( - new ForbiddenException('You do not have permission to invite members') - ); - - await expect(controller.inviteEmployee(orgId, inviteDto, mockAuthHeader)).rejects.toThrow( - ForbiddenException - ); - }); - }); - - // ============================================================================ - // POST /auth/organizations/accept-invitation - // ============================================================================ - - describe('POST /auth/organizations/accept-invitation', () => { - it('should accept an invitation', async () => { - const acceptDto = { invitationId: 'invitation-123' }; - - const expectedResult = { - member: { id: 'member-123', userId: 'user-456', organizationId: 'org-123', role: 'member' }, - organization: { id: 'org-123', name: 'Acme Corp' }, - }; - - betterAuthService.acceptInvitation.mockResolvedValue(expectedResult as any); - - const result = await controller.acceptInvitation(acceptDto, mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.acceptInvitation).toHaveBeenCalledWith({ - invitationId: 'invitation-123', - userToken: mockToken, - }); - }); - - it('should throw NotFoundException when invitation not found', async () => { - const acceptDto = { invitationId: 'invalid-invitation' }; - - betterAuthService.acceptInvitation.mockRejectedValue( - new NotFoundException('Invitation not found or expired') - ); - - await expect(controller.acceptInvitation(acceptDto, mockAuthHeader)).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // DELETE /auth/organizations/:id/members/:memberId - // ============================================================================ - - describe('DELETE /auth/organizations/:id/members/:memberId', () => { - it('should remove a member from organization', async () => { - const orgId = 'org-123'; - const memberId = 'member-456'; - - const expectedResult = { success: true, message: 'Member removed successfully' }; - - betterAuthService.removeMember.mockResolvedValue(expectedResult); - - const result = await controller.removeMember(orgId, memberId, mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.removeMember).toHaveBeenCalledWith({ - organizationId: orgId, - memberId, - removerToken: mockToken, - }); - }); - - it('should throw ForbiddenException when remover lacks permission', async () => { - betterAuthService.removeMember.mockRejectedValue( - new ForbiddenException('You do not have permission to remove members') - ); - - await expect( - controller.removeMember('org-123', 'member-456', mockAuthHeader) - ).rejects.toThrow(ForbiddenException); - }); - }); - - // ============================================================================ - // POST /auth/organizations/set-active - // ============================================================================ - - describe('POST /auth/organizations/set-active', () => { - it('should set active organization', async () => { - const setActiveDto = { organizationId: 'org-123' }; - - const expectedResult = { - userId: 'user-123', - activeOrganizationId: 'org-123', - }; - - betterAuthService.setActiveOrganization.mockResolvedValue(expectedResult as any); - - const result = await controller.setActiveOrganization(setActiveDto, mockAuthHeader); - - expect(result).toEqual(expectedResult); - expect(betterAuthService.setActiveOrganization).toHaveBeenCalledWith({ - organizationId: 'org-123', - userToken: mockToken, - }); - }); - - it('should throw NotFoundException when not a member', async () => { - const setActiveDto = { organizationId: 'org-999' }; - - betterAuthService.setActiveOrganization.mockRejectedValue( - new NotFoundException('Organization not found or you are not a member') - ); - - await expect(controller.setActiveOrganization(setActiveDto, mockAuthHeader)).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // Guard Tests - // ============================================================================ - - describe('Guards', () => { - it('should have JwtAuthGuard on protected endpoints', () => { - const protectedEndpoints: (keyof AuthController)[] = [ - 'logout', - 'getSession', - 'listOrganizations', - 'getOrganization', - 'getOrganizationMembers', - 'inviteEmployee', - 'acceptInvitation', - 'removeMember', - 'setActiveOrganization', - ]; - - protectedEndpoints.forEach((endpoint) => { - const guards = Reflect.getMetadata( - '__guards__', - AuthController.prototype[endpoint as keyof AuthController] - ); - expect(guards).toBeDefined(); - expect(guards).toContain(JwtAuthGuard); - }); - }); - - it('should NOT have JwtAuthGuard on public endpoints', () => { - const publicEndpoints: (keyof AuthController)[] = [ - 'register', - 'login', - 'refresh', - 'validate', - 'registerB2B', - ]; - - publicEndpoints.forEach((endpoint) => { - const guards = Reflect.getMetadata( - '__guards__', - AuthController.prototype[endpoint as keyof AuthController] - ); - expect(guards).toBeUndefined(); - }); - }); - }); - - // ============================================================================ - // Token Extraction Helper - // ============================================================================ - - describe('Token Extraction', () => { - it('should extract token from Bearer authorization header', async () => { - betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); - - await controller.logout('Bearer my-token-123', mockReq); - - expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123'); - }); - - it('should handle missing authorization header', async () => { - betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' }); - - await controller.logout('', mockReq); - - expect(betterAuthService.signOut).toHaveBeenCalledWith(''); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts deleted file mode 100644 index b13e662e6..000000000 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ /dev/null @@ -1,1051 +0,0 @@ -import { - Controller, - Post, - Get, - Put, - Patch, - Delete, - Body, - Param, - UseGuards, - Headers, - HttpCode, - HttpStatus, - Req, - Res, - ForbiddenException, -} from '@nestjs/common'; -import type { Request, Response } from 'express'; -import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; -import { BetterAuthService } from './services/better-auth.service'; -import { PasskeyService } from './services/passkey.service'; -import { RegisterDto } from './dto/register.dto'; -import { LoginDto } from './dto/login.dto'; -import { RefreshTokenDto } from './dto/refresh-token.dto'; -import { RegisterB2BDto } from './dto/register-b2b.dto'; -import { InviteEmployeeDto } from './dto/invite-employee.dto'; -import { AcceptInvitationDto } from './dto/accept-invitation.dto'; -import { SetActiveOrganizationDto } from './dto/set-active-organization.dto'; -import { ForgotPasswordDto } from './dto/forgot-password.dto'; -import { ResetPasswordDto } from './dto/reset-password.dto'; -import { ResendVerificationDto } from './dto/resend-verification.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ChangePasswordDto } from './dto/change-password.dto'; -import { DeleteAccountDto } from './dto/delete-account.dto'; -import { UpdateOrganizationDto } from './dto/update-organization.dto'; -import { UpdateMemberRoleDto } from './dto/update-member-role.dto'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; - -/** - * Auth Controller - * - * Handles authentication and organization management endpoints. - * - * B2C Endpoints: - * - POST /auth/register - Register individual user - * - POST /auth/login - Sign in with email/password - * - POST /auth/logout - Sign out - * - POST /auth/refresh - Refresh access token - * - GET /auth/session - Get current session - * - * B2B Organization Endpoints: - * - POST /auth/register/b2b - Register organization with owner - * - GET /auth/organizations - List user's organizations - * - GET /auth/organizations/:id - Get organization details - * - PUT /auth/organizations/:id - Update organization - * - DELETE /auth/organizations/:id - Delete organization (owner only) - * - POST /auth/organizations/:id/invite - Invite employee - * - GET /auth/organizations/:id/members - List organization members - * - DELETE /auth/organizations/:id/members/:memberId - Remove member - * - PATCH /auth/organizations/:orgId/members/:memberId/role - Update member role - * - GET /auth/organizations/:id/invitations - List organization invitations - * - POST /auth/organizations/accept-invitation - Accept invitation - * - POST /auth/organizations/set-active - Switch active organization - * - * Invitation Endpoints: - * - GET /auth/invitations - List user's pending invitations - * - DELETE /auth/invitations/:id - Cancel or reject invitation - */ -@ApiTags('auth') -@Controller('auth') -@UseGuards(ThrottlerGuard) -export class AuthController { - constructor( - private readonly betterAuthService: BetterAuthService, - private readonly securityEvents: SecurityEventsService, - private readonly accountLockout: AccountLockoutService, - private readonly passkeyService: PasskeyService - ) {} - - // ========================================================================= - // B2C Authentication Endpoints - // ========================================================================= - - /** - * Register a new B2C user (individual) - * - * Creates a user account and initializes their credit balance. - * Rate limited to 5 requests per minute to prevent abuse. - */ - @Post('register') - @Throttle({ default: { ttl: 60000, limit: 5 } }) - @ApiOperation({ - summary: 'Register new user', - description: 'Create a new B2C user account. Rate limited to 5 requests/minute.', - }) - @ApiBody({ type: RegisterDto }) - @ApiResponse({ status: 201, description: 'User created successfully' }) - @ApiResponse({ status: 400, description: 'Invalid input data' }) - @ApiResponse({ status: 409, description: 'Email already exists' }) - @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) - async register(@Body() registerDto: RegisterDto, @Req() req: Request) { - const result = await this.betterAuthService.registerB2C({ - email: registerDto.email, - password: registerDto.password, - name: registerDto.name || '', - sourceAppUrl: registerDto.sourceAppUrl, - }); - - this.securityEvents.logEventWithRequest(req, { - userId: result.user?.id, - eventType: SecurityEventType.REGISTER, - metadata: { email: registerDto.email }, - }); - - return result; - } - - /** - * Sign in with email and password - * - * Returns user data and JWT token. - * Rate limited to 10 requests per minute to prevent brute force. - */ - @Post('login') - @Throttle({ default: { ttl: 60000, limit: 10 } }) - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'User login', - description: 'Authenticate with email and password. Returns JWT access token.', - }) - @ApiBody({ type: LoginDto }) - @ApiResponse({ - status: 200, - description: 'Login successful', - schema: { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - id: { type: 'string' }, - email: { type: 'string' }, - name: { type: 'string' }, - }, - }, - accessToken: { type: 'string' }, - refreshToken: { type: 'string' }, - expiresIn: { type: 'number', example: 900 }, - }, - }, - }) - @ApiResponse({ status: 401, description: 'Invalid credentials' }) - @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) - async login(@Body() loginDto: LoginDto, @Req() req: Request) { - const { ipAddress, userAgent } = this.securityEvents.extractRequestInfo(req); - - // Check account lockout before attempting login - const lockout = await this.accountLockout.checkLockout(loginDto.email); - if (lockout.locked) { - this.securityEvents.logEventWithRequest(req, { - eventType: SecurityEventType.LOGIN_FAILURE, - metadata: { email: loginDto.email, reason: 'account_locked' }, - }); - throw new ForbiddenException({ - message: 'Account temporarily locked due to too many failed login attempts', - code: 'ACCOUNT_LOCKED', - retryAfter: lockout.remainingSeconds, - }); - } - - try { - const result = await this.betterAuthService.signIn({ - email: loginDto.email, - password: loginDto.password, - deviceId: loginDto.deviceId, - deviceName: loginDto.deviceName, - }); - - // Login successful - clear failed attempts and log - this.accountLockout.clearAttempts(loginDto.email); - this.securityEvents.logEvent({ - userId: result.user?.id, - eventType: SecurityEventType.LOGIN_SUCCESS, - ipAddress, - userAgent, - metadata: { email: loginDto.email, deviceId: loginDto.deviceId }, - }); - - return result; - } catch (error) { - // Don't count email-not-verified as a failed login attempt - if (error instanceof ForbiddenException) { - throw error; - } - - // Record failed attempt - this.accountLockout.recordAttempt(loginDto.email, false, ipAddress); - this.securityEvents.logEvent({ - eventType: SecurityEventType.LOGIN_FAILURE, - ipAddress, - userAgent, - metadata: { email: loginDto.email, reason: 'invalid_credentials' }, - }); - - throw error; - } - } - - /** - * Sign out current user - * - * Invalidates the user's session. - */ - @Post('logout') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'User logout', - description: 'Invalidate the current session', - }) - @ApiResponse({ status: 200, description: 'Logout successful' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async logout(@Headers('authorization') authorization: string, @Req() req: Request) { - const token = this.extractToken(authorization); - const result = await this.betterAuthService.signOut(token); - - this.securityEvents.logEventWithRequest(req, { - eventType: SecurityEventType.LOGOUT, - }); - - return result; - } - - /** - * Refresh access token - * - * Uses refresh token rotation to issue new access and refresh tokens. - */ - @Post('refresh') - @HttpCode(HttpStatus.OK) - async refresh(@Body() refreshTokenDto: RefreshTokenDto) { - return this.betterAuthService.refreshToken(refreshTokenDto.refreshToken); - } - - /** - * Get current session - * - * Returns the current user and session data. - */ - @Get('session') - @UseGuards(JwtAuthGuard) - async getSession(@Headers('authorization') authorization: string) { - const token = this.extractToken(authorization); - return this.betterAuthService.getSession(token); - } - - /** - * Validate a token - * - * Checks if a token is valid and returns the payload. - */ - @Post('validate') - @HttpCode(HttpStatus.OK) - async validate(@Body() body: { token: string }) { - return this.betterAuthService.validateToken(body.token); - } - - /** - * Exchange session cookie for JWT tokens (SSO) - * - * This endpoint enables cross-domain Single Sign-On (SSO). - * If the user has a valid session cookie (from logging in on another app), - * this returns JWT tokens that the app can use for API calls. - * - * The session cookie is set on .mana.how domain, so it's shared across: - * - calendar.mana.how - * - todo.mana.how - * - contacts.mana.how - * - etc. - */ - @Post('session-to-token') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Exchange session cookie for JWT tokens', - description: - 'SSO endpoint: If user has a valid session cookie, returns JWT access and refresh tokens.', - }) - @ApiResponse({ - status: 200, - description: 'Tokens generated successfully', - schema: { - type: 'object', - properties: { - user: { - type: 'object', - properties: { - id: { type: 'string' }, - email: { type: 'string' }, - name: { type: 'string' }, - }, - }, - accessToken: { type: 'string' }, - refreshToken: { type: 'string' }, - expiresIn: { type: 'number', example: 900 }, - }, - }, - }) - @ApiResponse({ status: 401, description: 'No valid session cookie' }) - async sessionToToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) { - const result = await this.betterAuthService.sessionToToken(req, res); - - this.securityEvents.logEventWithRequest(req, { - userId: result.user?.id, - eventType: SecurityEventType.SSO_TOKEN_EXCHANGE, - metadata: { email: result.user?.email }, - }); - - return result; - } - - /** - * Get JWKS (JSON Web Key Set) - * - * Returns public keys for JWT verification. - * This is a passthrough to Better Auth's JWKS. - */ - @Get('jwks') - async getJwks() { - return this.betterAuthService.getJwks(); - } - - // ========================================================================= - // Password Reset Endpoints - // ========================================================================= - - /** - * Request password reset - * - * Initiates the password reset flow by sending an email with a reset link. - * Always returns success to prevent email enumeration attacks. - * Rate limited to 3 requests per minute to prevent abuse. - */ - @Post('forgot-password') - @Throttle({ default: { ttl: 60000, limit: 3 } }) - @HttpCode(HttpStatus.OK) - async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto, @Req() req: Request) { - const result = await this.betterAuthService.requestPasswordReset( - forgotPasswordDto.email, - forgotPasswordDto.redirectTo - ); - - this.securityEvents.logEventWithRequest(req, { - eventType: SecurityEventType.PASSWORD_RESET_REQUESTED, - metadata: { email: forgotPasswordDto.email }, - }); - - return result; - } - - /** - * Reset password with token - * - * Completes the password reset using the token from the email link. - * Rate limited to 5 requests per minute. - */ - @Post('reset-password') - @Throttle({ default: { ttl: 60000, limit: 5 } }) - @HttpCode(HttpStatus.OK) - async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Req() req: Request) { - const result = await this.betterAuthService.resetPassword( - resetPasswordDto.token, - resetPasswordDto.newPassword - ); - - this.securityEvents.logEventWithRequest(req, { - eventType: SecurityEventType.PASSWORD_RESET_COMPLETED, - }); - - return result; - } - - /** - * Resend verification email - * - * Sends a new verification email to the user. - * Always returns success to prevent email enumeration attacks. - * Rate limited to 3 requests per minute to prevent abuse. - */ - @Post('resend-verification') - @Throttle({ default: { ttl: 60000, limit: 3 } }) - @HttpCode(HttpStatus.OK) - async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) { - return this.betterAuthService.resendVerificationEmail( - resendVerificationDto.email, - resendVerificationDto.sourceAppUrl - ); - } - - // ========================================================================= - // Profile Management Endpoints - // ========================================================================= - - /** - * Get current user profile - * - * Returns the authenticated user's profile data. - */ - @Get('profile') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get current user profile' }) - @ApiResponse({ - status: 200, - description: 'Returns user profile', - schema: { - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - email: { type: 'string' }, - emailVerified: { type: 'boolean' }, - image: { type: 'string' }, - role: { type: 'string' }, - createdAt: { type: 'string', format: 'date-time' }, - }, - }, - }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async getProfile(@CurrentUser() user: CurrentUserData) { - return this.betterAuthService.getProfile(user.userId); - } - - /** - * Update user profile - * - * Updates the user's name and/or profile image. - */ - @Post('profile') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Update user profile' }) - @ApiBody({ type: UpdateProfileDto }) - @ApiResponse({ status: 200, description: 'Profile updated successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async updateProfile(@CurrentUser() user: CurrentUserData, @Body() updateDto: UpdateProfileDto) { - return this.betterAuthService.updateProfile(user.userId, { - name: updateDto.name, - image: updateDto.image, - }); - } - - /** - * Change password - * - * Changes the user's password. Requires current password for verification. - * Rate limited to 5 requests per minute. - */ - @Post('change-password') - @UseGuards(JwtAuthGuard) - @Throttle({ default: { ttl: 60000, limit: 5 } }) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Change password' }) - @ApiBody({ type: ChangePasswordDto }) - @ApiResponse({ status: 200, description: 'Password changed successfully' }) - @ApiResponse({ status: 401, description: 'Current password is incorrect' }) - async changePassword( - @CurrentUser() user: CurrentUserData, - @Body() changeDto: ChangePasswordDto, - @Req() req: Request - ) { - const result = await this.betterAuthService.changePassword( - user.userId, - changeDto.currentPassword, - changeDto.newPassword - ); - - this.securityEvents.logEventWithRequest(req, { - userId: user.userId, - eventType: SecurityEventType.PASSWORD_CHANGED, - }); - - return result; - } - - /** - * Delete account - * - * Soft-deletes the user's account. Requires password confirmation. - * Rate limited to 3 requests per minute. - */ - @Delete('account') - @UseGuards(JwtAuthGuard) - @Throttle({ default: { ttl: 60000, limit: 3 } }) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete user account' }) - @ApiBody({ type: DeleteAccountDto }) - @ApiResponse({ status: 200, description: 'Account deleted' }) - @ApiResponse({ status: 401, description: 'Password is incorrect' }) - async deleteAccount( - @CurrentUser() user: CurrentUserData, - @Body() deleteDto: DeleteAccountDto, - @Req() req: Request - ) { - const result = await this.betterAuthService.deleteAccount( - user.userId, - deleteDto.password, - deleteDto.reason - ); - - this.securityEvents.logEventWithRequest(req, { - userId: user.userId, - eventType: SecurityEventType.ACCOUNT_DELETED, - metadata: { reason: deleteDto.reason }, - }); - - return result; - } - - // ========================================================================= - // B2B Registration - // ========================================================================= - - /** - * Register a new B2B organization - * - * Creates an organization with the registering user as owner. - * Also creates organization credit balance. - * Rate limited to 3 requests per minute. - */ - @Post('register/b2b') - @Throttle({ default: { ttl: 60000, limit: 3 } }) - async registerB2B(@Body() registerDto: RegisterB2BDto) { - return this.betterAuthService.registerB2B(registerDto); - } - - // ========================================================================= - // Organization Management Endpoints - // ========================================================================= - - /** - * List user's organizations - * - * Returns all organizations the current user is a member of. - */ - @Get('organizations') - @UseGuards(JwtAuthGuard) - async listOrganizations(@Headers('authorization') authorization: string) { - const token = this.extractToken(authorization); - return this.betterAuthService.listOrganizations(token); - } - - /** - * Get organization details - * - * Returns full organization info including members. - */ - @Get('organizations/:id') - @UseGuards(JwtAuthGuard) - async getOrganization( - @Param('id') organizationId: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.getOrganization(organizationId, token); - } - - /** - * Get organization members - * - * Returns all members of an organization with their roles. - */ - @Get('organizations/:id/members') - @UseGuards(JwtAuthGuard) - async getOrganizationMembers(@Param('id') organizationId: string) { - return this.betterAuthService.getOrganizationMembers(organizationId); - } - - /** - * Invite employee to organization - * - * Sends an invitation email to join the organization. - * Requires owner or admin role. - */ - @Post('organizations/:id/invite') - @UseGuards(JwtAuthGuard) - async inviteEmployee( - @Param('id') organizationId: string, - @Body() inviteDto: InviteEmployeeDto, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.inviteEmployee({ - organizationId, - employeeEmail: inviteDto.employeeEmail, - role: inviteDto.role, - inviterToken: token, - }); - } - - /** - * Accept organization invitation - * - * Accepts a pending invitation and adds user to organization. - */ - @Post('organizations/accept-invitation') - @UseGuards(JwtAuthGuard) - async acceptInvitation( - @Body() acceptDto: AcceptInvitationDto, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.acceptInvitation({ - invitationId: acceptDto.invitationId, - userToken: token, - }); - } - - /** - * Remove member from organization - * - * Removes a member from the organization. - * Requires owner or admin role. - */ - @Delete('organizations/:id/members/:memberId') - @UseGuards(JwtAuthGuard) - async removeMember( - @Param('id') organizationId: string, - @Param('memberId') memberId: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.removeMember({ - organizationId, - memberId, - removerToken: token, - }); - } - - /** - * Set active organization - * - * Switches the user's active organization context. - * Affects JWT claims and credit balance. - */ - @Post('organizations/set-active') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async setActiveOrganization( - @Body() setActiveDto: SetActiveOrganizationDto, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.setActiveOrganization({ - organizationId: setActiveDto.organizationId, - userToken: token, - }); - } - - /** - * Update organization - * - * Updates an organization's name, logo, or metadata. - * Requires owner or admin role. - */ - @Put('organizations/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Update organization', - description: 'Update organization name, logo, or metadata. Requires admin or owner role.', - }) - @ApiBody({ type: UpdateOrganizationDto }) - @ApiResponse({ status: 200, description: 'Organization updated successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - @ApiResponse({ status: 403, description: 'No permission to update organization' }) - @ApiResponse({ status: 404, description: 'Organization not found' }) - async updateOrganization( - @Param('id') id: string, - @Body() dto: UpdateOrganizationDto, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.updateOrganization(id, dto, token); - } - - /** - * Delete organization - * - * Permanently deletes an organization and all its data. - * Requires owner role. - */ - @Delete('organizations/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.NO_CONTENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Delete organization', - description: 'Permanently delete an organization. Only the owner can delete.', - }) - @ApiResponse({ status: 204, description: 'Organization deleted successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - @ApiResponse({ status: 403, description: 'Only owner can delete organization' }) - @ApiResponse({ status: 404, description: 'Organization not found' }) - async deleteOrganization( - @Param('id') id: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - await this.betterAuthService.deleteOrganization(id, token); - } - - /** - * Update member role - * - * Changes a member's role within an organization. - * Requires owner or admin role. - */ - @Patch('organizations/:orgId/members/:memberId/role') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Update member role', - description: "Change a member's role. Requires admin or owner role.", - }) - @ApiBody({ type: UpdateMemberRoleDto }) - @ApiResponse({ status: 200, description: 'Member role updated successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - @ApiResponse({ status: 403, description: 'No permission to change roles' }) - @ApiResponse({ status: 404, description: 'Member not found' }) - async updateMemberRole( - @Param('orgId') orgId: string, - @Param('memberId') memberId: string, - @Body() dto: UpdateMemberRoleDto, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.updateMemberRole(orgId, memberId, dto.role, token); - } - - /** - * List organization invitations - * - * Returns all pending invitations for an organization. - * Requires owner or admin role. - */ - @Get('organizations/:id/invitations') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'List organization invitations', - description: 'Get all pending invitations for an organization.', - }) - @ApiResponse({ status: 200, description: 'Returns list of invitations' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async listOrganizationInvitations( - @Param('id') id: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - return this.betterAuthService.listOrganizationInvitations(id, token); - } - - /** - * List user's pending invitations - * - * Returns all pending invitations for the authenticated user. - */ - @Get('invitations') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'List user invitations', - description: 'Get all pending invitations for the current user.', - }) - @ApiResponse({ status: 200, description: 'Returns list of invitations' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async listUserInvitations(@Headers('authorization') authorization: string) { - const token = this.extractToken(authorization); - return this.betterAuthService.listUserInvitations(token); - } - - /** - * Cancel or reject invitation - * - * Cancels an invitation (for org admins) or rejects it (for invitees). - * The system automatically determines which action to take based on the user's role. - */ - @Delete('invitations/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.NO_CONTENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: 'Cancel or reject invitation', - description: 'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.', - }) - @ApiResponse({ status: 204, description: 'Invitation cancelled/rejected successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - @ApiResponse({ status: 404, description: 'Invitation not found' }) - async cancelOrRejectInvitation( - @Param('id') id: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - // Try cancel first (for org owners/admins), if fails try reject (for invitees) - try { - await this.betterAuthService.cancelInvitation(id, token); - } catch { - await this.betterAuthService.rejectInvitation(id, token); - } - } - - // ========================================================================= - // Security Events - // ========================================================================= - - /** - * Get user security events (audit log) - * - * Returns the authenticated user's security events ordered by most recent first. - */ - @Get('security-events') - @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Get user security events (audit log)' }) - @ApiBearerAuth('JWT-auth') - @ApiResponse({ status: 200, description: 'Returns security events' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async getSecurityEvents(@CurrentUser() user: CurrentUserData, @Req() req: Request) { - return this.betterAuthService.getSecurityEvents(user.userId); - } - - // ========================================================================= - // Session Management - // ========================================================================= - - /** - * List active sessions for the current user - */ - @Get('sessions') - @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'List active sessions' }) - @ApiBearerAuth('JWT-auth') - @ApiResponse({ status: 200, description: 'Returns list of active sessions' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - async listSessions(@CurrentUser() user: CurrentUserData) { - return this.betterAuthService.listSessions(user.userId); - } - - /** - * Revoke a specific session - */ - @Delete('sessions/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Revoke a session' }) - @ApiBearerAuth('JWT-auth') - @ApiResponse({ status: 204, description: 'Session revoked successfully' }) - @ApiResponse({ status: 401, description: 'Not authenticated' }) - @ApiResponse({ status: 404, description: 'Session not found' }) - async revokeSession( - @CurrentUser() user: CurrentUserData, - @Param('id') sessionId: string, - @Req() req: Request - ) { - await this.betterAuthService.revokeSession(user.userId, sessionId); - this.securityEvents.logEventWithRequest(req, { - userId: user.userId, - eventType: SecurityEventType.LOGOUT, - metadata: { revokedSessionId: sessionId }, - }); - } - - // ========================================================================= - // Passkey (WebAuthn) Endpoints - // ========================================================================= - - /** - * Generate passkey registration options - * - * Returns WebAuthn registration options for the authenticated user. - * The user must be logged in to register a passkey. - */ - @Post('passkeys/register/options') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Generate passkey registration options' }) - async passkeyRegisterOptions(@CurrentUser() user: CurrentUserData) { - return this.passkeyService.generateRegistrationOptions(user.userId); - } - - /** - * Verify and store passkey registration - * - * Verifies the WebAuthn registration response and stores the passkey. - */ - @Post('passkeys/register/verify') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Verify and store passkey registration' }) - async passkeyRegisterVerify( - @CurrentUser() user: CurrentUserData, - @Body() body: { challengeId: string; credential: any; friendlyName?: string }, - @Req() req: Request - ) { - const result = await this.passkeyService.verifyRegistration( - body.challengeId, - body.credential, - body.friendlyName - ); - await this.securityEvents.logEvent({ - userId: user.userId, - eventType: SecurityEventType.PASSKEY_REGISTERED, - ipAddress: req.ip, - userAgent: req.headers['user-agent'] as string, - metadata: { passkeyId: result.id }, - }); - return result; - } - - /** - * Generate passkey authentication options - * - * Returns WebAuthn authentication options. No auth required. - */ - @Post('passkeys/authenticate/options') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Generate passkey authentication options' }) - async passkeyAuthOptions() { - return this.passkeyService.generateAuthenticationOptions(); - } - - /** - * Verify passkey authentication and return JWT tokens - * - * Verifies the WebAuthn authentication response and returns - * JWT access and refresh tokens (same format as login). - */ - @Post('passkeys/authenticate/verify') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Verify passkey authentication and return JWT tokens' }) - async passkeyAuthVerify( - @Body() body: { challengeId: string; credential: any }, - @Req() req: Request - ) { - const { user, passkeyId } = await this.passkeyService.verifyAuthentication( - body.challengeId, - body.credential - ); - - // Generate session + JWT tokens (same pattern as signIn) - const tokenResult = await this.betterAuthService.createSessionAndTokens(user, { - ipAddress: req.ip, - userAgent: req.headers['user-agent'] as string, - }); - - await this.securityEvents.logEvent({ - userId: user.id, - eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS, - ipAddress: req.ip, - userAgent: req.headers['user-agent'] as string, - metadata: { passkeyId }, - }); - - return tokenResult; - } - - /** - * List user's passkeys - * - * Returns all passkeys registered by the authenticated user. - */ - @Get('passkeys') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'List user passkeys' }) - async listPasskeys(@CurrentUser() user: CurrentUserData) { - return this.passkeyService.listPasskeys(user.userId); - } - - /** - * Delete a passkey - * - * Removes a passkey from the user's account. - */ - @Delete('passkeys/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.NO_CONTENT) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Delete a passkey' }) - async deletePasskey( - @CurrentUser() user: CurrentUserData, - @Param('id') passkeyId: string, - @Req() req: Request - ) { - await this.passkeyService.deletePasskey(user.userId, passkeyId); - await this.securityEvents.logEvent({ - userId: user.userId, - eventType: SecurityEventType.PASSKEY_DELETED, - ipAddress: req.ip, - userAgent: req.headers['user-agent'] as string, - metadata: { passkeyId }, - }); - } - - /** - * Rename a passkey - * - * Updates the friendly name of a passkey. - */ - @Patch('passkeys/:id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Rename a passkey' }) - async renamePasskey( - @CurrentUser() user: CurrentUserData, - @Param('id') passkeyId: string, - @Body() body: { friendlyName: string } - ) { - await this.passkeyService.renamePasskey(user.userId, passkeyId, body.friendlyName); - return { success: true }; - } - - // ========================================================================= - // Helper Methods - // ========================================================================= - - /** - * Extract token from Authorization header - */ - private extractToken(authorization: string): string { - if (!authorization) { - return ''; - } - // Handle both "Bearer token" and raw token formats - if (authorization.startsWith('Bearer ')) { - return authorization.substring(7); - } - return authorization; - } -} diff --git a/services/mana-core-auth/src/auth/auth.module.ts b/services/mana-core-auth/src/auth/auth.module.ts deleted file mode 100644 index 94d09b23c..000000000 --- a/services/mana-core-auth/src/auth/auth.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { AuthController } from './auth.controller'; -import { BetterAuthPassthroughController } from './better-auth-passthrough.controller'; -import { OidcController } from './oidc.controller'; -import { OidcLoginController } from './oidc-login.controller'; -import { MatrixSessionController } from './matrix-session.controller'; -import { BetterAuthService } from './services/better-auth.service'; -import { MatrixSessionService } from './services/matrix-session.service'; -import { PasskeyService } from './services/passkey.service'; -import { SecurityModule } from '../security'; - -@Module({ - imports: [SecurityModule, ConfigModule], - controllers: [ - AuthController, - BetterAuthPassthroughController, - OidcController, - OidcLoginController, - MatrixSessionController, - ], - providers: [BetterAuthService, MatrixSessionService, PasskeyService], - exports: [BetterAuthService, MatrixSessionService, PasskeyService], -}) -export class AuthModule {} diff --git a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts deleted file mode 100644 index 49a4d4096..000000000 --- a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Better Auth Passthrough Controller - * - * This controller handles Better Auth's native routes that are generated - * with the `/api/auth/*` prefix (without the NestJS `/api/v1` prefix). - * - * Routes handled: - * - GET /api/auth/get-session - SSO session check (cookie-based) - * - GET /api/auth/verify-email - Email verification from verification emails - * - GET /api/auth/reset-password/:token - Password reset from reset emails - * - * This is necessary because Better Auth generates URLs with `/api/auth/*` - * but our NestJS API uses `/api/v1/*` as the global prefix. - */ - -import { Controller, Get, Post, All, Param, Query, Req, Res, HttpStatus } from '@nestjs/common'; -import { Request, Response } from 'express'; -import { ConfigService } from '@nestjs/config'; -import { BetterAuthService } from './services/better-auth.service'; -import { LoggerService } from '../common/logger'; - -@Controller('api/auth') -export class BetterAuthPassthroughController { - private readonly defaultFrontendUrl = 'https://mana.how'; - private readonly logger: LoggerService; - - constructor( - private readonly betterAuthService: BetterAuthService, - private readonly configService: ConfigService, - loggerService: LoggerService - ) { - this.logger = loggerService.setContext('BetterAuthPassthrough'); - } - - /** - * Forward requests to Better Auth's handler - * - * Converts Express request to Fetch Request and passes it to Better Auth. - * Copies response status, headers (including Set-Cookie), and body back. - */ - private async forwardToBetterAuth(req: Request, res: Response) { - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const url = new URL(req.originalUrl, baseUrl); - - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value && typeof value === 'string') { - headers.set(key, value); - } else if (Array.isArray(value)) { - headers.set(key, value[0]); - } - } - - const fetchRequest = new Request(url.toString(), { - method: req.method, - headers, - body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, - }); - - const handler = this.betterAuthService.getHandler(); - const response = await handler(fetchRequest); - - res.status(response.status); - - response.headers.forEach((value: string, key: string) => { - if (key.toLowerCase() === 'set-cookie') { - res.append(key, value); - } else { - res.setHeader(key, value); - } - }); - - const body = await response.text(); - try { - return res.json(JSON.parse(body)); - } catch { - return res.send(body); - } - } - - /** - * Two-Factor Authentication passthrough - * - * Forwards all /api/auth/two-factor/* requests to Better Auth's handler. - * The twoFactor plugin registers these routes: - * - POST /two-factor/enable - * - POST /two-factor/disable - * - POST /two-factor/verify-totp - * - POST /two-factor/verify-backup-code - * - POST /two-factor/get-totp-uri - * - POST /two-factor/generate-backup-codes - */ - @All('two-factor/*') - async twoFactorPassthrough(@Req() req: Request, @Res() res: Response) { - try { - return await this.forwardToBetterAuth(req, res); - } catch (error) { - this.logger.error( - 'Two-factor passthrough failed', - error instanceof Error ? error.stack : undefined - ); - return res.status(500).json({ error: 'Two-factor request failed' }); - } - } - - /** - * Magic Link passthrough - * - * Forwards all /api/auth/magic-link/* requests to Better Auth's handler. - * The magicLink plugin registers these routes: - * - POST /magic-link/send-magic-link - * - GET /magic-link/verify (callback from email) - */ - @All('magic-link/*') - async handleMagicLink(@Req() req: Request, @Res() res: Response) { - try { - return await this.forwardToBetterAuth(req, res); - } catch (error) { - this.logger.error( - 'Magic link passthrough failed', - error instanceof Error ? error.stack : undefined - ); - return res.status(500).json({ error: 'Magic link request failed' }); - } - } - - /** - * Handle SSO get-session request - * - * This endpoint is called by client apps to check if the user has a valid - * session cookie (set by auth.mana.how). Used for cross-subdomain SSO. - * - * The request includes cookies, and we forward them to Better Auth's handler. - */ - @Get('get-session') - async getSession(@Req() req: Request, @Res() res: Response) { - try { - // Build the cookie header from Express request - const cookieHeader = req.headers.cookie || ''; - - // Get the base URL from config - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const url = new URL('/api/auth/get-session', baseUrl); - - // Create a fetch Request with the cookies - const headers = new Headers({ - Cookie: cookieHeader, - }); - - const fetchRequest = new Request(url.toString(), { - method: 'GET', - headers, - }); - - // Forward to Better Auth's handler - const handler = this.betterAuthService.getHandler(); - const response = await handler(fetchRequest); - - // Copy status and body to Express response - const body = await response.json(); - return res.status(response.status).json(body); - } catch (error) { - this.logger.error('SSO get-session failed', error instanceof Error ? error.stack : undefined); - return res.status(401).json({ error: 'Session check failed' }); - } - } - - /** - * Validate redirect URL for security - * - * Only allows redirects to: - * - *.mana.how domains - * - mana.how (main domain) - * - localhost (for development) - * - * @param redirectTo - URL to validate - * @returns Validated origin URL or null if invalid - */ - private validateRedirectUrl(redirectTo?: string): string | null { - if (!redirectTo) return null; - - try { - const url = new URL(redirectTo); - - // Allow *.mana.how, mana.how, and localhost - if ( - url.hostname.endsWith('.mana.how') || - url.hostname === 'mana.how' || - url.hostname === 'localhost' - ) { - return url.origin; - } - } catch { - // Invalid URL, return null - } - - return null; - } - - /** - * Handle email verification - * - * Better Auth sends verification emails with links to: - * {baseURL}/api/auth/verify-email?token=...&redirectTo=... - * - * This endpoint: - * 1. Calls Better Auth's verifyEmail API - * 2. Gets the source app URL from the store (set during registration) - * 3. Redirects the user to the app's login page with verified=true and email - */ - @Get('verify-email') - async verifyEmail( - @Query('token') token: string, - @Query('redirectTo') redirectTo: string | undefined, - @Res() res: Response - ) { - const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl; - - try { - if (!token) { - return res.redirect(`${fallbackUrl}/verification-failed?error=missing_token`); - } - - // Call Better Auth's verifyEmail API - const result = await this.betterAuthService.verifyEmail(token); - - if (result.success) { - const email = result.email || ''; - - // Determine redirect URL: - // 1. First try the redirectTo query param (passed through URL) - // 2. Then try the sourceAppStore (set during registration) - // 3. Finally fall back to default frontend URL - let baseUrl = this.validateRedirectUrl(redirectTo); - - if (!baseUrl && email) { - // Try to get source app URL from store (set during registration) - const storedUrl = this.betterAuthService.getSourceAppUrl(email); - baseUrl = this.validateRedirectUrl(storedUrl || undefined); - } - - if (!baseUrl) { - baseUrl = fallbackUrl; - } - - // Redirect to app's login page with verified=true and email - const loginUrl = new URL('/login', baseUrl); - loginUrl.searchParams.set('verified', 'true'); - if (email) { - loginUrl.searchParams.set('email', email); - } - - return res.redirect(loginUrl.toString()); - } else { - // Redirect to error page - return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`); - } - } catch (error) { - this.logger.error( - 'Email verification failed', - error instanceof Error ? error.stack : undefined - ); - return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`); - } - } - - /** - * Handle password reset link from email - * - * Better Auth sends password reset emails with links to: - * {baseURL}/api/auth/reset-password/{token}?callbackURL=... - * - * This endpoint: - * 1. Extracts the reset token from the URL - * 2. Redirects the user to the frontend /reset-password page with the token - * 3. The frontend then shows a form to enter the new password - * 4. Frontend submits to POST /api/v1/auth/reset-password with token + newPassword - */ - @Get('reset-password/:token') - async resetPassword( - @Param('token') token: string, - @Query('callbackURL') callbackURL: string | undefined, - @Res() res: Response - ) { - const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl; - - try { - if (!token) { - return res.redirect(`${fallbackUrl}/login?error=missing_reset_token`); - } - - // Determine redirect URL: - // 1. First try the callbackURL query param (from the email link) - // 2. Fall back to default frontend URL - let baseUrl = this.validateRedirectUrl(callbackURL); - - if (!baseUrl) { - baseUrl = fallbackUrl; - } - - // Redirect to frontend's reset-password page with token - const resetUrl = new URL('/reset-password', baseUrl); - resetUrl.searchParams.set('token', token); - - this.logger.debug('Password reset redirect', { destination: baseUrl }); - return res.redirect(resetUrl.toString()); - } catch (error) { - this.logger.error( - 'Password reset redirect failed', - error instanceof Error ? error.stack : undefined - ); - return res.redirect(`${fallbackUrl}/login?error=reset_failed`); - } - } -} diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts deleted file mode 100644 index 2af01860a..000000000 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ /dev/null @@ -1,451 +0,0 @@ -/** - * Better Auth Configuration - * - * This file configures Better Auth with: - * - Email/password authentication - * - Organization plugin for B2B (multi-tenant) - * - JWT plugin with minimal claims - * - Drizzle adapter for PostgreSQL - * - * ARCHITECTURE DECISION (2024-12): - * We use MINIMAL JWT claims. Organization and credit data should be fetched - * via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md - * - * @see https://www.better-auth.com/docs - */ - -import { betterAuth } from 'better-auth'; -import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { jwt } from 'better-auth/plugins/jwt'; -import { organization } from 'better-auth/plugins/organization'; -import { oidcProvider } from 'better-auth/plugins/oidc-provider'; -import { twoFactor } from 'better-auth/plugins/two-factor'; -import { magicLink } from 'better-auth/plugins/magic-link'; -import { getDb } from '../db/connection'; -import { organizations, members, invitations } from '../db/schema/organizations.schema'; -import { - users, - sessions, - accounts, - verificationTokens, - jwks, - oauthApplications, - oauthAccessTokens, - oauthAuthorizationCodes, - oauthConsents, - twoFactorAuth, -} from '../db/schema/auth.schema'; -import type { JWTPayloadContext } from './types/better-auth.types'; -import { - sendPasswordResetEmail, - sendInvitationEmail, - sendVerificationEmail, - sendMagicLinkEmail, -} from '../email/email.service'; -import { sourceAppStore } from './stores/source-app.store'; -import { passwordResetRedirectStore } from './stores/password-reset-redirect.store'; - -/** - * JWT Custom Payload Interface - * - * MINIMAL claims only. Organization context and credits are available via: - * - GET /organization/get-active-member - org membership & role - * - GET /api/v1/credits/balance - credit balance - * - * Why minimal claims? - * 1. Credit balance changes frequently - JWT would be stale - * 2. Organization context available via Better Auth org plugin APIs - * 3. Smaller tokens = better performance - * 4. Follows Better Auth's session-based design - */ -export interface JWTCustomPayload { - /** User ID (standard JWT claim) */ - sub: string; - - /** User email */ - email: string; - - /** User role (user, admin, service) */ - role: string; - - /** Session ID for reference */ - sid: string; -} - -/** - * Create Better Auth instance - * - * @param databaseUrl - PostgreSQL connection URL - * @returns Better Auth instance - */ -export function createBetterAuth(databaseUrl: string) { - const db = getDb(databaseUrl); - - return betterAuth({ - // Database adapter (Drizzle with PostgreSQL) - database: drizzleAdapter(db, { - provider: 'pg', - schema: { - // Auth tables (actual Drizzle table objects) - user: users, - session: sessions, - account: accounts, - verification: verificationTokens, - - // Organization tables - organization: organizations, - member: members, - invitation: invitations, - - // JWT plugin table - jwks: jwks, - - // Two-Factor Authentication table - twoFactor: twoFactorAuth, - - // OIDC Provider tables - oauthApplication: oauthApplications, - oauthAccessToken: oauthAccessTokens, - oauthAuthorizationCode: oauthAuthorizationCodes, - oauthConsent: oauthConsents, - }, - }), - - // Email/password authentication with password reset - emailAndPassword: { - enabled: true, - requireEmailVerification: true, - minPasswordLength: 8, - maxPasswordLength: 128, - - /** - * Password Reset Configuration - * - * Better Auth provides password reset via: - * - auth.api.requestPasswordReset({ body: { email } }) - Sends reset email - * - auth.api.resetPassword({ body: { newPassword, token } }) - Resets password - * - * The reset URL is modified to include callbackURL parameter - * so users are redirected back to the app they requested reset from. - * - * @see https://www.better-auth.com/docs/authentication/email-password#password-reset - */ - sendResetPassword: async ({ - user, - url, - }: { - user: { email: string; name: string }; - url: string; - }) => { - // Check if we have a redirect URL stored for this user's password reset request - const redirectUrl = passwordResetRedirectStore.get(user.email); - - // Modify reset URL to include callbackURL parameter - let resetUrl = url; - if (redirectUrl) { - const urlObj = new URL(url); - urlObj.searchParams.set('callbackURL', redirectUrl); - resetUrl = urlObj.toString(); - } - - await sendPasswordResetEmail(user.email, resetUrl, user.name); - }, - }, - - /** - * Email Verification Configuration - * - * Sends verification email when user registers. - * User must verify email before they can log in. - * - * The verification URL is modified to include redirectTo parameter - * so users are redirected back to the app they registered from. - */ - emailVerification: { - sendOnSignUp: true, - autoSignInAfterVerification: true, - sendVerificationEmail: async ({ - user, - url, - }: { - user: { email: string; name: string }; - url: string; - }) => { - // Check if we have a source app URL stored for this user - // Note: We get the URL without deleting it here since it might be needed - // during the verification process in the passthrough controller - const sourceAppUrl = sourceAppStore.get(user.email); - - // Modify verification URL to include redirectTo parameter - let verificationUrl = url; - if (sourceAppUrl) { - const urlObj = new URL(url); - urlObj.searchParams.set('redirectTo', sourceAppUrl); - verificationUrl = urlObj.toString(); - } - - await sendVerificationEmail(user.email, verificationUrl, user.name); - }, - }, - - // Session configuration - session: { - expiresIn: 60 * 60 * 24 * 7, // 7 days - updateAge: 60 * 60 * 24, // Update session once per day - }, - - // Base URL for callbacks and redirects - baseURL: process.env.BASE_URL || 'http://localhost:3001', - - /** - * Advanced Cookie Configuration for Cross-Domain SSO - * - * By setting the cookie domain to '.mana.how', session cookies are shared - * across all subdomains (calendar.mana.how, todo.mana.how, etc.). - * This enables Single Sign-On: login once, authenticated everywhere. - * - * For local development (localhost), leave domain undefined to use default behavior. - */ - advanced: { - // Cookie prefix for all auth cookies - cookiePrefix: 'mana', - - // Cross-subdomain cookie configuration - crossSubDomainCookies: { - // Enable cross-subdomain cookies in production - enabled: !!process.env.COOKIE_DOMAIN, - // Domain for cookies (e.g., '.mana.how' - note the leading dot) - domain: process.env.COOKIE_DOMAIN || undefined, - }, - - // Default cookie options for all auth cookies - defaultCookieAttributes: { - // Secure in production, allow http in development - secure: process.env.NODE_ENV === 'production', - // SameSite=None is required for cross-subdomain SSO via fetch() - // Lax only sends cookies on top-level navigations, not programmatic fetch() - // None requires Secure=true (ensured by production check above) - sameSite: process.env.COOKIE_DOMAIN ? ('none' as const) : ('lax' as const), - // Cookies accessible to all paths - path: '/', - // Prevent JavaScript access to cookies - httpOnly: true, - }, - }, - - // Trusted origins for cross-origin requests (must match CORS_ORIGINS in production) - // IMPORTANT: Every app that uses SSO must be listed here, otherwise - // Better Auth will reject cross-origin requests with credentials. - // When adding a new app, add its production domain here AND to - // CORS_ORIGINS in docker-compose.macmini.yml. - trustedOrigins: [ - // Production domains - auth service - 'https://auth.mana.how', - 'https://mana.how', - // Production domains - all apps (keep alphabetical) - 'https://calendar.mana.how', - 'https://chat.mana.how', - 'https://clock.mana.how', - 'https://contacts.mana.how', - 'https://context.mana.how', - 'https://docs.mana.how', - 'https://element.mana.how', - 'https://inventar.mana.how', - 'https://link.mana.how', - 'https://manadeck.mana.how', - 'https://matrix.mana.how', - 'https://mchat.mana.how', - 'https://mukke.mana.how', - 'https://nutriphi.mana.how', - 'https://photos.mana.how', - 'https://picture.mana.how', - 'https://planta.mana.how', - 'https://playground.mana.how', - 'https://presi.mana.how', - 'https://questions.mana.how', - 'https://skilltree.mana.how', - 'https://storage.mana.how', - 'https://todo.mana.how', - 'https://traces.mana.how', - 'https://zitare.mana.how', - // Local development - 'http://localhost:3001', - 'http://localhost:5173', - 'http://localhost:5174', - 'http://localhost:5190', - ], - - // Plugins - plugins: [ - /** - * Organization Plugin (B2B) - * - * Provides complete organization management: - * - Create/update/delete organizations - * - Invite/add/remove members - * - Role-based access control - * - Active organization tracking (session.activeOrganizationId) - * - * Client apps use these endpoints for org context: - * - GET /organization/get-active-member - * - GET /organization/get-active-member-role - * - POST /organization/set-active - */ - organization({ - // Allow users to create their own organizations - allowUserToCreateOrganization: true, - - // Email invitation handler - async sendInvitationEmail(data) { - const { email, organization, inviter } = data; - const baseUrl = process.env.BASE_URL || 'https://mana.how'; - const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`; - await sendInvitationEmail( - email, - organization.name, - inviter?.user?.name || 'Ein Teammitglied', - inviteUrl - ); - }, - - // Custom roles and permissions - organizationRole: { - owner: { - permissions: [ - 'organization:update', - 'organization:delete', - 'members:invite', - 'members:remove', - 'members:update_role', - 'credits:allocate', - 'credits:view_all', - ], - }, - admin: { - permissions: [ - 'organization:update', - 'members:invite', - 'members:remove', - 'credits:view_all', - ], - }, - member: { - permissions: ['credits:view_own'], - }, - }, - }), - - /** - * JWT Plugin - * - * Generates JWT tokens with MINIMAL claims. - * - * DO NOT add complex claims like: - * - credit_balance (stale after 15min, fetch via API instead) - * - organization details (use Better Auth org plugin APIs) - * - customer_type (derive from activeOrganizationId presence) - * - * Apps should call APIs for dynamic data: - * - Credits: GET /api/v1/credits/balance - * - Org info: GET /organization/get-active-member - */ - jwt({ - jwt: { - // 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', - expirationTime: '15m', - - /** - * Define minimal JWT payload - * - * Only includes static user info that doesn't change frequently. - */ - definePayload({ user, session }: JWTPayloadContext) { - return { - sub: user.id, - email: user.email, - role: (user as { role?: string }).role || 'user', - sid: session.id, - }; - }, - }, - }), - - /** - * OIDC Provider Plugin - * - * Enables Mana Core Auth to act as an OpenID Connect Provider. - * This allows Matrix/Synapse and other services to use SSO. - * - * Endpoints provided: - * - GET /.well-known/openid-configuration - * - GET /api/oidc/authorize - * - POST /api/oidc/token - * - GET /api/oidc/userinfo - * - GET /api/oidc/jwks - */ - oidcProvider({ - // Login page for OIDC authorization - loginPage: '/login', - // Consent page (skipped for trusted clients) - consentPage: '/consent', - // Use JWT plugin for token signing (EdDSA instead of HS256) - // This is required for Synapse OIDC which verifies via JWKS - useJWTPlugin: true, - metadata: { - issuer: process.env.BASE_URL || 'http://localhost:3001', - }, - // Trusted clients that skip consent screen - // These clients are considered first-party and don't need user consent - trustedClients: [ - { - clientId: 'matrix-synapse', - clientSecret: process.env.SYNAPSE_OIDC_CLIENT_SECRET || '', - name: 'Matrix Synapse', - type: 'web', - disabled: false, - metadata: {}, - redirectUrls: ['https://matrix.mana.how/_synapse/client/oidc/callback'], - skipConsent: true, - }, - ], - }), - /** - * Two-Factor Authentication Plugin (TOTP) - * - * Provides TOTP-based 2FA with backup codes. - * Endpoints provided automatically by Better Auth passthrough: - * - POST /two-factor/enable (requires password) - * - POST /two-factor/disable (requires password) - * - POST /two-factor/verify-totp (during login) - * - POST /two-factor/verify-backup-code (during login) - * - POST /two-factor/get-totp-uri - * - POST /two-factor/generate-backup-codes - */ - twoFactor({ - issuer: 'ManaCore', - }), - /** - * Magic Link Plugin (Passwordless Email Login) - * - * Sends a one-time login link via email. - * Endpoints via Better Auth passthrough: - * - POST /magic-link/send-magic-link - * - GET /magic-link/verify (callback from email) - */ - magicLink({ - sendMagicLink: async ({ email, url }: { email: string; url: string }) => { - await sendMagicLinkEmail(email, url); - }, - expiresIn: 600, // 10 minutes - }), - ], - }); -} - -/** - * Export type for Better Auth instance - */ -export type BetterAuthInstance = ReturnType; diff --git a/services/mana-core-auth/src/auth/dto/accept-invitation.dto.ts b/services/mana-core-auth/src/auth/dto/accept-invitation.dto.ts deleted file mode 100644 index ed641cf6a..000000000 --- a/services/mana-core-auth/src/auth/dto/accept-invitation.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsString } from 'class-validator'; - -/** - * DTO for accepting an organization invitation - */ -export class AcceptInvitationDto { - @IsString() - invitationId: string; -} diff --git a/services/mana-core-auth/src/auth/dto/change-password.dto.ts b/services/mana-core-auth/src/auth/dto/change-password.dto.ts deleted file mode 100644 index 23d034c15..000000000 --- a/services/mana-core-auth/src/auth/dto/change-password.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsString, MinLength, MaxLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class ChangePasswordDto { - @ApiProperty({ description: 'Current password', example: 'currentPassword123' }) - @IsString() - @MinLength(1) - currentPassword: string; - - @ApiProperty({ description: 'New password (min 8 characters)', example: 'newSecurePassword456' }) - @IsString() - @MinLength(8) - @MaxLength(128) - newPassword: string; -} diff --git a/services/mana-core-auth/src/auth/dto/delete-account.dto.ts b/services/mana-core-auth/src/auth/dto/delete-account.dto.ts deleted file mode 100644 index 01eb60f58..000000000 --- a/services/mana-core-auth/src/auth/dto/delete-account.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsString, IsOptional, MinLength } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class DeleteAccountDto { - @ApiProperty({ - description: 'Current password to confirm account deletion', - example: 'myPassword123', - }) - @IsString() - @MinLength(1) - password: string; - - @ApiPropertyOptional({ - description: 'Optional reason for leaving', - example: 'I found a better service', - }) - @IsOptional() - @IsString() - reason?: string; -} diff --git a/services/mana-core-auth/src/auth/dto/forgot-password.dto.ts b/services/mana-core-auth/src/auth/dto/forgot-password.dto.ts deleted file mode 100644 index 22a1c8bb8..000000000 --- a/services/mana-core-auth/src/auth/dto/forgot-password.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsEmail, IsOptional, IsString, IsUrl } from 'class-validator'; - -/** - * Forgot Password DTO - * - * Request body for initiating password reset. - */ -export class ForgotPasswordDto { - /** - * User's email address - */ - @IsEmail() - email: string; - - /** - * Optional redirect URL after password reset - * The reset token will be appended as a query parameter - */ - @IsOptional() - @IsString() - redirectTo?: string; -} diff --git a/services/mana-core-auth/src/auth/dto/index.ts b/services/mana-core-auth/src/auth/dto/index.ts deleted file mode 100644 index 92ab40644..000000000 --- a/services/mana-core-auth/src/auth/dto/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Auth DTOs Index - * - * Re-exports all authentication-related DTOs - */ - -// Core auth DTOs -export { RegisterDto } from './register.dto'; -export { LoginDto } from './login.dto'; -export { RefreshTokenDto } from './refresh-token.dto'; - -// B2B organization DTOs -export { RegisterB2BDto } from './register-b2b.dto'; -export { InviteEmployeeDto } from './invite-employee.dto'; -export { AcceptInvitationDto } from './accept-invitation.dto'; -export { SetActiveOrganizationDto } from './set-active-organization.dto'; -export { UpdateOrganizationDto } from './update-organization.dto'; -export { UpdateMemberRoleDto } from './update-member-role.dto'; - -// Password management DTOs -export { ForgotPasswordDto } from './forgot-password.dto'; -export { ResetPasswordDto } from './reset-password.dto'; -export { ResendVerificationDto } from './resend-verification.dto'; - -// Profile management DTOs -export { UpdateProfileDto } from './update-profile.dto'; -export { ChangePasswordDto } from './change-password.dto'; -export { DeleteAccountDto } from './delete-account.dto'; diff --git a/services/mana-core-auth/src/auth/dto/invite-employee.dto.ts b/services/mana-core-auth/src/auth/dto/invite-employee.dto.ts deleted file mode 100644 index 5e1a86119..000000000 --- a/services/mana-core-auth/src/auth/dto/invite-employee.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsEmail, IsString, IsIn } from 'class-validator'; - -/** - * DTO for inviting an employee to an organization - * - * Only owners and admins can invite new members. - */ -export class InviteEmployeeDto { - @IsString() - organizationId: string; - - @IsEmail() - employeeEmail: string; - - @IsString() - @IsIn(['admin', 'member']) - role: 'admin' | 'member'; -} diff --git a/services/mana-core-auth/src/auth/dto/login.dto.ts b/services/mana-core-auth/src/auth/dto/login.dto.ts deleted file mode 100644 index 0dd86442a..000000000 --- a/services/mana-core-auth/src/auth/dto/login.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IsEmail, IsString, IsOptional } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class LoginDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'User password', - example: 'SecurePassword123!', - }) - @IsString() - password: string; - - @ApiPropertyOptional({ - description: 'Unique device identifier for session tracking', - example: 'device-uuid-123', - }) - @IsString() - @IsOptional() - deviceId?: string; - - @ApiPropertyOptional({ - description: 'Human-readable device name', - example: 'iPhone 15 Pro', - }) - @IsString() - @IsOptional() - deviceName?: string; -} diff --git a/services/mana-core-auth/src/auth/dto/refresh-token.dto.ts b/services/mana-core-auth/src/auth/dto/refresh-token.dto.ts deleted file mode 100644 index f6f774103..000000000 --- a/services/mana-core-auth/src/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from 'class-validator'; - -export class RefreshTokenDto { - @IsString() - refreshToken: string; -} diff --git a/services/mana-core-auth/src/auth/dto/register-b2b.dto.ts b/services/mana-core-auth/src/auth/dto/register-b2b.dto.ts deleted file mode 100644 index f4962d3b3..000000000 --- a/services/mana-core-auth/src/auth/dto/register-b2b.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'; - -/** - * DTO for B2B organization registration - * - * Creates an organization with the registering user as owner. - */ -export class RegisterB2BDto { - @IsEmail() - ownerEmail: string; - - @IsString() - @MinLength(12) - @MaxLength(128) - password: string; - - @IsString() - @MaxLength(255) - ownerName: string; - - @IsString() - @MinLength(2) - @MaxLength(255) - organizationName: string; -} diff --git a/services/mana-core-auth/src/auth/dto/register.dto.ts b/services/mana-core-auth/src/auth/dto/register.dto.ts deleted file mode 100644 index f8eb34bd0..000000000 --- a/services/mana-core-auth/src/auth/dto/register.dto.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsUrl } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class RegisterDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - }) - @IsEmail() - email: string; - - @ApiProperty({ - description: 'User password (min 8 characters)', - example: 'SecurePassword123!', - minLength: 8, - maxLength: 128, - }) - @IsString() - @MinLength(8) - @MaxLength(128) - password: string; - - @ApiPropertyOptional({ - description: 'User display name', - example: 'John Doe', - maxLength: 255, - }) - @IsString() - @IsOptional() - @MaxLength(255) - name?: string; - - @ApiPropertyOptional({ - description: 'URL of the source app for redirect after registration', - example: 'https://app.example.com', - maxLength: 255, - }) - @IsString() - @IsOptional() - @IsUrl({ require_tld: false }) // Allow localhost URLs for development - @MaxLength(255) - sourceAppUrl?: string; -} diff --git a/services/mana-core-auth/src/auth/dto/resend-verification.dto.ts b/services/mana-core-auth/src/auth/dto/resend-verification.dto.ts deleted file mode 100644 index 3c081dfa7..000000000 --- a/services/mana-core-auth/src/auth/dto/resend-verification.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsEmail, IsOptional, IsString } from 'class-validator'; - -export class ResendVerificationDto { - @IsEmail() - email: string; - - @IsOptional() - @IsString() - sourceAppUrl?: string; -} diff --git a/services/mana-core-auth/src/auth/dto/reset-password.dto.ts b/services/mana-core-auth/src/auth/dto/reset-password.dto.ts deleted file mode 100644 index cf6d0ad1f..000000000 --- a/services/mana-core-auth/src/auth/dto/reset-password.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsString, MinLength, MaxLength } from 'class-validator'; - -/** - * Reset Password DTO - * - * Request body for resetting password with token. - */ -export class ResetPasswordDto { - /** - * Reset token from email link - */ - @IsString() - token: string; - - /** - * New password (must meet password requirements) - */ - @IsString() - @MinLength(8, { message: 'Password must be at least 8 characters long' }) - @MaxLength(128, { message: 'Password must be at most 128 characters long' }) - newPassword: string; -} diff --git a/services/mana-core-auth/src/auth/dto/set-active-organization.dto.ts b/services/mana-core-auth/src/auth/dto/set-active-organization.dto.ts deleted file mode 100644 index 10ec0f48c..000000000 --- a/services/mana-core-auth/src/auth/dto/set-active-organization.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsString } from 'class-validator'; - -/** - * DTO for setting the active organization - * - * Used to switch between organizations for users with multiple memberships. - */ -export class SetActiveOrganizationDto { - @IsString() - organizationId: string; -} diff --git a/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts b/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts deleted file mode 100644 index a54842f74..000000000 --- a/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsString, IsIn } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -/** - * DTO for updating a member's role within an organization - * - * Note: 'owner' role cannot be assigned via this endpoint. - * To transfer ownership, use the dedicated transfer ownership endpoint. - */ -export class UpdateMemberRoleDto { - @ApiProperty({ - description: 'New role for the member', - enum: ['admin', 'member'], - example: 'admin', - }) - @IsString() - @IsIn(['admin', 'member']) - role: 'admin' | 'member'; -} diff --git a/services/mana-core-auth/src/auth/dto/update-organization.dto.ts b/services/mana-core-auth/src/auth/dto/update-organization.dto.ts deleted file mode 100644 index 7093fecd6..000000000 --- a/services/mana-core-auth/src/auth/dto/update-organization.dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IsString, IsOptional, MaxLength, MinLength } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -/** - * DTO for updating an organization - * - * All fields are optional - only provided fields will be updated. - */ -export class UpdateOrganizationDto { - @ApiPropertyOptional({ - description: 'New name for the organization', - minLength: 2, - maxLength: 255, - example: 'Acme Corporation', - }) - @IsString() - @IsOptional() - @MinLength(2) - @MaxLength(255) - name?: string; - - @ApiPropertyOptional({ - description: 'URL to organization logo', - maxLength: 500, - example: 'https://example.com/logo.png', - }) - @IsString() - @IsOptional() - @MaxLength(500) - logo?: string; - - @ApiPropertyOptional({ - description: 'Additional metadata for the organization', - example: { industry: 'Technology', size: 'Enterprise' }, - }) - @IsOptional() - metadata?: Record; -} diff --git a/services/mana-core-auth/src/auth/dto/update-profile.dto.ts b/services/mana-core-auth/src/auth/dto/update-profile.dto.ts deleted file mode 100644 index 39532ae0a..000000000 --- a/services/mana-core-auth/src/auth/dto/update-profile.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IsString, IsOptional, IsEmail, MinLength, MaxLength, IsUrl } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class UpdateProfileDto { - @ApiPropertyOptional({ description: 'New display name', example: 'Max Mustermann' }) - @IsOptional() - @IsString() - @MinLength(2) - @MaxLength(100) - name?: string; - - @ApiPropertyOptional({ - description: 'Profile image URL', - example: 'https://example.com/avatar.jpg', - }) - @IsOptional() - @IsUrl() - image?: string; -} diff --git a/services/mana-core-auth/src/auth/jwt-validation.spec.ts b/services/mana-core-auth/src/auth/jwt-validation.spec.ts deleted file mode 100644 index 29586b58f..000000000 --- a/services/mana-core-auth/src/auth/jwt-validation.spec.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * JWT Token Validation Tests (Minimal Claims) - * - * Tests for JWT token validation with minimal claims: - * - sub (user ID) - * - email - * - role - * - sid (session ID) - * - * ARCHITECTURE DECISION (2024-12): - * We use MINIMAL JWT claims. Organization and credit data should be fetched - * via API calls, not embedded in JWTs. See docs/AUTHENTICATION_ARCHITECTURE.md - * - * Why minimal claims? - * 1. Credit balance changes frequently - JWT would be stale - * 2. Organization context available via Better Auth org plugin APIs - * 3. Smaller tokens = better performance - * 4. Follows Better Auth's session-based design - * - * NOTE: These tests use jose library (EdDSA/HS256) as per project guidelines. - * Production uses EdDSA via Better Auth's JWKS. - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { type ConfigService } from '@nestjs/config'; -import { SignJWT, jwtVerify, errors } from 'jose'; -import { type JWTCustomPayload } from './better-auth.config'; -import { createMockConfigService } from '../__tests__/utils/test-helpers'; -import { mockUserFactory } from '../__tests__/utils/mock-factories'; - -// Mock external dependencies -jest.mock('../db/connection'); -jest.mock('nanoid', () => ({ - nanoid: jest.fn(() => 'mock-nanoid-123'), -})); - -// Helper to create JWT using jose -async function signJwt( - payload: JWTCustomPayload, - secret: Uint8Array, - options: { expiresIn?: string; issuer?: string; audience?: string; notBefore?: number } = {} -): Promise { - const jwt = new SignJWT(payload as unknown as Record) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt(); - - if (options.expiresIn) { - jwt.setExpirationTime(options.expiresIn); - } - if (options.issuer) { - jwt.setIssuer(options.issuer); - } - if (options.audience) { - jwt.setAudience(options.audience); - } - if (options.notBefore !== undefined) { - jwt.setNotBefore(options.notBefore); - } - - return jwt.sign(secret); -} - -// Helper to verify JWT using jose -async function verifyJwt( - token: string, - secret: Uint8Array, - options: { issuer?: string; audience?: string } = {} -): Promise { - const { payload } = await jwtVerify(token, secret, { - algorithms: ['HS256'], - issuer: options.issuer, - audience: options.audience, - }); - return payload as unknown as JWTCustomPayload; -} - -describe('JWT Token Validation (Minimal Claims)', () => { - let configService: ConfigService; - let mockDb: any; - let secret: Uint8Array; - - beforeEach(async () => { - // Use HS256 for testing (symmetric key) for simplicity - // In production, mana-core uses EdDSA via Better Auth's JWKS - secret = new TextEncoder().encode('test-secret-key-for-jwt-validation-must-be-32-chars'); - - // Create mock database - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - transaction: jest.fn(), - }; - - // Mock getDb - const { getDb } = require('../db/connection'); - getDb.mockReturnValue(mockDb); - - configService = createMockConfigService({ - 'jwt.issuer': 'mana-core', - 'jwt.audience': 'manacore', - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('Minimal JWT Claims Structure', () => { - it('should generate token with minimal claims only', async () => { - const user = mockUserFactory.create({ - id: 'user-123', - email: 'user@example.com', - role: 'user', - }); - - const payload: JWTCustomPayload = { - sub: user.id, - email: user.email, - role: user.role, - sid: 'session-abc-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret, { - issuer: 'mana-core', - audience: 'manacore', - }); - - expect(decoded).toMatchObject({ - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-abc-123', - }); - - // Verify NO complex claims are present - expect((decoded as any).customer_type).toBeUndefined(); - expect((decoded as any).organization).toBeUndefined(); - expect((decoded as any).credit_balance).toBeUndefined(); - expect((decoded as any).app_id).toBeUndefined(); - expect((decoded as any).device_id).toBeUndefined(); - }); - - it('should include standard JWT claims (sub, iat, exp, iss, aud)', async () => { - const now = Math.floor(Date.now() / 1000); - - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - // Standard JWT claims - expect(decoded.sub).toBe('user-123'); - expect((decoded as any).iat).toBeGreaterThanOrEqual(now); - expect((decoded as any).exp).toBeGreaterThan((decoded as any).iat); - expect((decoded as any).iss).toBe('mana-core'); - expect((decoded as any).aud).toBe('manacore'); - }); - - it('should support different user roles', async () => { - const roles = ['user', 'admin', 'service']; - - for (const role of roles) { - const payload: JWTCustomPayload = { - sub: `${role}-user-123`, - email: `${role}@example.com`, - role, - sid: `session-${role}`, - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - expect(decoded.role).toBe(role); - } - }); - }); - - describe('Token Validation - Security', () => { - it('should validate HS256 signature correctly', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - // Should successfully verify with correct secret - await expect(verifyJwt(token, secret)).resolves.toBeDefined(); - }); - - it('should reject expired tokens', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - // Create token that expires immediately - const token = await signJwt(payload, secret, { - expiresIn: '0s', // Expired immediately - issuer: 'mana-core', - audience: 'manacore', - }); - - // Wait a moment to ensure expiry - await new Promise((resolve) => setTimeout(resolve, 100)); - - await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTExpired); - }); - - it('should reject tokens with wrong issuer', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'wrong-issuer', // Wrong issuer - audience: 'manacore', - }); - - await expect( - verifyJwt(token, secret, { - issuer: 'mana-core', // Expect correct issuer - audience: 'manacore', - }) - ).rejects.toThrow(errors.JWTClaimValidationFailed); - }); - - it('should reject tokens with wrong audience', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'wrong-audience', // Wrong audience - }); - - await expect( - verifyJwt(token, secret, { - issuer: 'mana-core', - audience: 'manacore', // Expect correct audience - }) - ).rejects.toThrow(errors.JWTClaimValidationFailed); - }); - - it('should reject tampered tokens', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - // Tamper with the token - try to change role to admin - const parts = token.split('.'); - const tamperedPayload = Buffer.from(JSON.stringify({ ...payload, role: 'admin' })).toString( - 'base64url' - ); - const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`; - - await expect(verifyJwt(tamperedToken, secret)).rejects.toThrow( - errors.JWSSignatureVerificationFailed - ); - }); - - it('should reject tokens signed with wrong secret', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - // Sign with different secret - const wrongSecret = new TextEncoder().encode('wrong-secret-key-for-testing-wrong'); - - const token = await signJwt(payload, wrongSecret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - // Try to verify with correct secret - await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWSSignatureVerificationFailed); - }); - }); - - describe('Token Expiration Times', () => { - it('should use 15 minutes for access tokens', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded: any = await verifyJwt(token, secret); - - const expiryTime = decoded.exp - decoded.iat; - expect(expiryTime).toBe(15 * 60); // 15 minutes = 900 seconds - }); - - it('should validate token is not yet valid (nbf claim)', async () => { - const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future - - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - notBefore: futureTime, // Not valid until 1 hour from now - issuer: 'mana-core', - audience: 'manacore', - }); - - await expect(verifyJwt(token, secret)).rejects.toThrow(errors.JWTClaimValidationFailed); - }); - }); - - describe('Edge Cases', () => { - it('should handle malformed JWT gracefully', async () => { - const malformedToken = 'this.is.not.a.valid.jwt'; - - await expect(verifyJwt(malformedToken, secret)).rejects.toThrow(); - }); - - it('should handle empty token', async () => { - await expect(verifyJwt('', secret)).rejects.toThrow(); - }); - - it('should handle token with missing required claims', async () => { - // Token with only sub (missing email, role, sid) - const minimalPayload = { sub: 'user-123' } as unknown as JWTCustomPayload; - - const token = await signJwt(minimalPayload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - // Token is technically valid, but application should validate claims - const decoded = await verifyJwt(token, secret); - - expect(decoded.sub).toBe('user-123'); - expect(decoded.email).toBeUndefined(); - expect(decoded.role).toBeUndefined(); - expect(decoded.sid).toBeUndefined(); - }); - }); - - describe('Token Refresh Behavior', () => { - it('should issue new token with same user claims', async () => { - const originalPayload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-original', - }; - - const originalToken = await signJwt(originalPayload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - // Refresh creates new token with new session ID - const refreshedPayload: JWTCustomPayload = { - ...originalPayload, - sid: 'session-refreshed', // New session ID - }; - - const refreshedToken = await signJwt(refreshedPayload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(refreshedToken, secret); - - // User claims should be maintained - expect(decoded.sub).toBe('user-123'); - expect(decoded.email).toBe('user@example.com'); - expect(decoded.role).toBe('user'); - // Session ID should be new - expect(decoded.sid).toBe('session-refreshed'); - }); - - it('should maintain user role across refreshes', async () => { - const adminPayload: JWTCustomPayload = { - sub: 'admin-123', - email: 'admin@example.com', - role: 'admin', - sid: 'session-123', - }; - - const token = await signJwt(adminPayload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - // Admin role should be preserved - expect(decoded.role).toBe('admin'); - }); - }); - - describe('Architecture Decision Documentation', () => { - /** - * This test documents what is NOT in the JWT by design. - * See docs/AUTHENTICATION_ARCHITECTURE.md for full explanation. - */ - it('should NOT contain organization data (fetch via API instead)', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - // Organization data should be fetched via: - // - session.activeOrganizationId (from Better Auth session) - // - GET /organization/get-active-member (for details) - expect((decoded as any).organization).toBeUndefined(); - expect((decoded as any).organizationId).toBeUndefined(); - }); - - it('should NOT contain credit balance (fetch via API instead)', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - // Credit balance should be fetched via: - // - GET /api/v1/credits/balance - // Credit balance changes too frequently to embed in JWT - expect((decoded as any).credit_balance).toBeUndefined(); - expect((decoded as any).credits).toBeUndefined(); - }); - - it('should NOT contain customer_type (derive from session instead)', async () => { - const payload: JWTCustomPayload = { - sub: 'user-123', - email: 'user@example.com', - role: 'user', - sid: 'session-123', - }; - - const token = await signJwt(payload, secret, { - expiresIn: '15m', - issuer: 'mana-core', - audience: 'manacore', - }); - - const decoded = await verifyJwt(token, secret); - - // Customer type should be derived from: - // - B2B = session.activeOrganizationId != null - // - B2C = session.activeOrganizationId == null - expect((decoded as any).customer_type).toBeUndefined(); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/magic-link.spec.ts b/services/mana-core-auth/src/auth/magic-link.spec.ts deleted file mode 100644 index 8f4a6e862..000000000 --- a/services/mana-core-auth/src/auth/magic-link.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Magic Link Passthrough Unit Tests - * - * Tests that the BetterAuthPassthroughController has the magic link - * handler method and that it delegates to forwardToBetterAuth. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { BetterAuthPassthroughController } from './better-auth-passthrough.controller'; -import { BetterAuthService } from './services/better-auth.service'; -import { LoggerService } from '../common/logger'; - -describe('BetterAuthPassthroughController - Magic Link', () => { - let controller: BetterAuthPassthroughController; - let betterAuthService: jest.Mocked; - - const mockBetterAuthService = { - getHandler: jest.fn(), - verifyEmail: jest.fn(), - getSourceAppUrl: jest.fn(), - }; - - const mockConfigService = { - get: jest.fn((key: string) => { - const config: Record = { - BASE_URL: 'http://localhost:3001', - }; - return config[key] || ''; - }), - }; - - const mockLoggerService = { - setContext: jest.fn().mockReturnThis(), - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [BetterAuthPassthroughController], - providers: [ - { provide: BetterAuthService, useValue: mockBetterAuthService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: LoggerService, useValue: mockLoggerService }, - ], - }).compile(); - - controller = module.get(BetterAuthPassthroughController); - betterAuthService = module.get(BetterAuthService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ============================================================================ - // Magic Link Handler Existence - // ============================================================================ - - describe('handleMagicLink', () => { - it('should have handleMagicLink method defined', () => { - expect(controller.handleMagicLink).toBeDefined(); - expect(typeof controller.handleMagicLink).toBe('function'); - }); - - it('should call forwardToBetterAuth and delegate to Better Auth handler', async () => { - const mockResponse = new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - - const mockHandler = jest.fn().mockResolvedValue(mockResponse); - betterAuthService.getHandler.mockReturnValue(mockHandler); - - const mockReq = { - method: 'POST', - originalUrl: '/api/auth/magic-link/send-magic-link', - headers: { 'content-type': 'application/json' }, - body: { email: 'test@example.com' }, - } as any; - - const mockRes = { - status: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - append: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), - } as any; - - await controller.handleMagicLink(mockReq, mockRes); - - expect(betterAuthService.getHandler).toHaveBeenCalled(); - expect(mockHandler).toHaveBeenCalled(); - }); - - it('should return 500 on internal error', async () => { - betterAuthService.getHandler.mockImplementation(() => { - throw new Error('Handler unavailable'); - }); - - const mockReq = { - method: 'POST', - originalUrl: '/api/auth/magic-link/send-magic-link', - headers: {}, - body: {}, - } as any; - - const mockRes = { - status: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - append: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), - } as any; - - await controller.handleMagicLink(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(500); - expect(mockRes.json).toHaveBeenCalledWith({ error: 'Magic link request failed' }); - }); - }); - - // ============================================================================ - // Route Metadata - // ============================================================================ - - describe('Route metadata', () => { - it('should have @All decorator on handleMagicLink for magic-link/* routes', () => { - const routePath = Reflect.getMetadata( - 'path', - BetterAuthPassthroughController.prototype.handleMagicLink - ); - expect(routePath).toBe('magic-link/*'); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/matrix-session.controller.ts b/services/mana-core-auth/src/auth/matrix-session.controller.ts deleted file mode 100644 index 5bd35d41b..000000000 --- a/services/mana-core-auth/src/auth/matrix-session.controller.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Param, - Body, - Headers, - UnauthorizedException, - NotFoundException, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { MatrixSessionService } from './services/matrix-session.service'; - -/** - * DTO for linking a Matrix user to a Mana account - */ -class LinkMatrixUserDto { - /** Matrix user ID (e.g., @user:matrix.mana.how) */ - matrixUserId!: string; - /** User's email (optional, for convenience) */ - email?: string; -} - -/** - * Matrix Session Controller - * - * Provides endpoints for Matrix bot authentication via SSO. - * - * Endpoints: - * - POST /api/v1/auth/matrix-user-links - Link Matrix user to Mana account - * - GET /api/v1/auth/matrix-session/:matrixUserId - Get JWT for linked Matrix user - * - DELETE /api/v1/auth/matrix-user-links/:matrixUserId - Unlink Matrix user - * - GET /api/v1/auth/matrix-user-links/check/:matrixUserId - Check if user is linked - * - * Authentication: - * - POST /link requires Bearer token (user authenticating) - * - GET /session requires X-Service-Key (internal bot service) - * - DELETE requires Bearer token (user unlinking) - * - GET /check requires X-Service-Key (internal bot service) - */ -@Controller('api/v1/auth') -export class MatrixSessionController { - constructor(private readonly matrixSessionService: MatrixSessionService) {} - - /** - * Link a Matrix user ID to a Mana account - * - * Called by bots after successful !login command. - * Requires the user's JWT token from login. - * - * @example - * POST /api/v1/auth/matrix-user-links - * Authorization: Bearer - * Body: { "matrixUserId": "@user:matrix.mana.how", "email": "user@example.com" } - */ - @Post('matrix-user-links') - @HttpCode(HttpStatus.CREATED) - async linkMatrixUser( - @Body() dto: LinkMatrixUserDto, - @Headers('authorization') authHeader?: string, - @Headers('x-service-key') serviceKey?: string - ): Promise<{ success: boolean; message: string }> { - // Two auth methods: Bearer token (from user login) or Service key (from bot) - let manaUserId: string; - - if (serviceKey && this.matrixSessionService.validateServiceKey(serviceKey)) { - // Service key auth - must provide userId in body - const bodyWithUserId = dto as LinkMatrixUserDto & { userId?: string }; - if (!bodyWithUserId.userId) { - throw new UnauthorizedException('userId required when using service key'); - } - manaUserId = bodyWithUserId.userId; - } else if (authHeader?.startsWith('Bearer ')) { - // JWT auth - extract user ID from token - const token = authHeader.substring(7); - const payload = this.decodeToken(token); - if (!payload?.sub) { - throw new UnauthorizedException('Invalid token'); - } - manaUserId = payload.sub; - } else { - throw new UnauthorizedException('Authentication required'); - } - - if (!dto.matrixUserId) { - throw new UnauthorizedException('matrixUserId is required'); - } - - await this.matrixSessionService.linkMatrixUser(dto.matrixUserId, manaUserId, dto.email); - - return { - success: true, - message: `Matrix user ${dto.matrixUserId} linked successfully`, - }; - } - - /** - * Get a JWT token for a linked Matrix user - * - * Called by bots to auto-authenticate users. - * Requires service key (internal service authentication). - * - * @example - * GET /api/v1/auth/matrix-session/@user:matrix.mana.how - * X-Service-Key: - */ - @Get('matrix-session/:matrixUserId') - async getMatrixSession( - @Param('matrixUserId') matrixUserId: string, - @Headers('x-service-key') serviceKey?: string - ): Promise<{ token: string; email: string }> { - // Require service key for this endpoint - if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) { - throw new UnauthorizedException('Valid service key required'); - } - - const result = await this.matrixSessionService.getSessionForMatrixUser( - decodeURIComponent(matrixUserId) - ); - - if (!result) { - throw new NotFoundException('No link found for this Matrix user'); - } - - return result; - } - - /** - * Unlink a Matrix user from a Mana account - * - * Called when user wants to disconnect their Matrix account. - * Requires the user's JWT token. - * - * @example - * DELETE /api/v1/auth/matrix-user-links/@user:matrix.mana.how - * Authorization: Bearer - */ - @Delete('matrix-user-links/:matrixUserId') - @HttpCode(HttpStatus.OK) - async unlinkMatrixUser( - @Param('matrixUserId') matrixUserId: string, - @Headers('authorization') authHeader?: string, - @Headers('x-service-key') serviceKey?: string - ): Promise<{ success: boolean; message: string }> { - // Allow both Bearer token and service key - if ( - !authHeader?.startsWith('Bearer ') && - !this.matrixSessionService.validateServiceKey(serviceKey || '') - ) { - throw new UnauthorizedException('Authentication required'); - } - - const deleted = await this.matrixSessionService.unlinkMatrixUser( - decodeURIComponent(matrixUserId) - ); - - if (!deleted) { - throw new NotFoundException('No link found for this Matrix user'); - } - - return { - success: true, - message: `Matrix user ${matrixUserId} unlinked successfully`, - }; - } - - /** - * Check if a Matrix user is linked - * - * Requires service key (internal service authentication). - * - * @example - * GET /api/v1/auth/matrix-user-links/check/@user:matrix.mana.how - * X-Service-Key: - */ - @Get('matrix-user-links/check/:matrixUserId') - async checkMatrixLink( - @Param('matrixUserId') matrixUserId: string, - @Headers('x-service-key') serviceKey?: string - ): Promise<{ linked: boolean }> { - // Require service key for this endpoint - if (!serviceKey || !this.matrixSessionService.validateServiceKey(serviceKey)) { - throw new UnauthorizedException('Valid service key required'); - } - - const linked = await this.matrixSessionService.isLinked(decodeURIComponent(matrixUserId)); - - return { linked }; - } - - /** - * Decode JWT token to get payload (without verification) - * Note: This is used only to extract user ID after the bot has verified the token - */ - private decodeToken(token: string): { sub?: string } | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - - const payload = Buffer.from(parts[1], 'base64url').toString('utf-8'); - return JSON.parse(payload); - } catch { - return null; - } - } -} diff --git a/services/mana-core-auth/src/auth/oidc-login.controller.ts b/services/mana-core-auth/src/auth/oidc-login.controller.ts deleted file mode 100644 index 4c83ad607..000000000 --- a/services/mana-core-auth/src/auth/oidc-login.controller.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * OIDC Login Controller - * - * Provides a simple login page for OIDC authorization flows. - * When users access the authorization endpoint without being logged in, - * Better Auth redirects them here. After successful login, users are - * redirected back to continue the authorization flow. - */ - -import { Controller, Get, Post, Req, Res, Body, Query } from '@nestjs/common'; -import { Request, Response } from 'express'; -import { BetterAuthService } from './services/better-auth.service'; - -@Controller() -export class OidcLoginController { - constructor(private readonly betterAuthService: BetterAuthService) {} - - /** - * GET /login - Display login page - * - * Shows a simple login form. OIDC parameters are preserved in the URL - * so they can be passed back to the authorization endpoint after login. - */ - @Get('login') - async getLoginPage(@Query() query: Record, @Res() res: Response) { - // Handle returnUrl parameter (when redirected from authorization endpoint) - let returnUrl = query.returnUrl || '/'; - let clientId: string | undefined = query.client_id; - - // If no direct client_id but we have returnUrl, extract client_id from it - if (!clientId && query.returnUrl) { - try { - const returnUrlParams = new URLSearchParams(query.returnUrl.split('?')[1] || ''); - clientId = returnUrlParams.get('client_id') ?? undefined; - } catch { - // Ignore parsing errors - } - } - - // If no returnUrl was provided, build one from query params (direct OIDC flow) - if (!query.returnUrl && Object.keys(query).length > 0) { - const queryString = new URLSearchParams(query).toString(); - returnUrl = `/api/auth/oauth2/authorize?${queryString}`; - } - - // Get client name for display - const clientName = this.getClientDisplayName(clientId || 'Unknown'); - - const html = ` - - - - - - Sign In - Mana Core - - - -
- - -
-

Signing in to ${clientName}

-
- -
- -
- - -
- - -
- -
- - -
- - -
- - -
- - - - -`; - - res.setHeader('Content-Type', 'text/html'); - return res.send(html); - } - - /** - * Get display name for OIDC client - */ - private getClientDisplayName(clientId: string): string { - const clientNames: Record = { - 'matrix-synapse': 'Matrix Chat', - }; - return clientNames[clientId] || clientId; - } -} diff --git a/services/mana-core-auth/src/auth/oidc.controller.ts b/services/mana-core-auth/src/auth/oidc.controller.ts deleted file mode 100644 index a1d947cad..000000000 --- a/services/mana-core-auth/src/auth/oidc.controller.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * OIDC Provider Controller - * - * Exposes Better Auth's OIDC Provider endpoints for external services - * like Matrix/Synapse to use SSO authentication. - * - * Better Auth exposes OIDC endpoints at /api/auth/oauth2/* paths. - * This controller provides routes at both: - * - /api/auth/oauth2/* (native Better Auth paths from discovery document) - * - /api/oidc/* (alternative paths for convenience) - * - * Endpoints: - * - GET /.well-known/openid-configuration - OIDC Discovery - * - GET /api/auth/oauth2/authorize - Authorization endpoint - * - POST /api/auth/oauth2/token - Token endpoint - * - GET /api/auth/oauth2/userinfo - UserInfo endpoint - * - GET /api/auth/jwks - JWKS endpoint - */ - -import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common'; -import { Request, Response } from 'express'; -import { BetterAuthService } from './services/better-auth.service'; -import { MatrixSessionService } from './services/matrix-session.service'; -import { LoggerService } from '../common/logger'; - -@Controller() -export class OidcController { - private readonly logger: LoggerService; - - constructor( - private readonly betterAuthService: BetterAuthService, - private readonly matrixSessionService: MatrixSessionService, - loggerService: LoggerService - ) { - this.logger = loggerService.setContext('OidcController'); - } - - /** - * OIDC Discovery Document - * - * Returns the OpenID Connect discovery document with all endpoints. - */ - @Get('.well-known/openid-configuration') - async getOpenIdConfiguration(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - // ============================================ - // Better Auth Native OAuth2 Endpoints - // These match the paths in the discovery document - // ============================================ - - /** - * Authorization Endpoint (Better Auth native path) - */ - @Get('api/auth/oauth2/authorize') - async authorizeOauth2(@Req() req: Request, @Res() res: Response) { - this.logger.debug('OIDC authorize request', { clientId: req.query.client_id }); - return this.handleOidcRequest(req, res); - } - - /** - * Token Endpoint (Better Auth native path) - * - * Accepts both JSON and form-urlencoded body (OAuth2 spec requires form-urlencoded). - * The body-parser middleware in main.ts parses form data into req.body object. - */ - @Post('api/auth/oauth2/token') - async tokenOauth2(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * UserInfo Endpoint (Better Auth native path) - * - * When Matrix/Synapse calls this endpoint, we automatically create - * the Matrix user link so bots can recognize the user without - * requiring a separate !login command. - */ - @Get('api/auth/oauth2/userinfo') - async userinfoOauth2(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequestWithMatrixLink(req, res); - } - - /** - * JWKS Endpoint (Better Auth native path) - */ - @Get('api/auth/jwks') - async jwksAuth(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * Catch-all for other Better Auth OAuth2 endpoints - */ - @All('api/auth/oauth2/*') - async catchAllOauth2(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * Better Auth Sign-in Endpoint - * - * This endpoint is needed for OIDC login flow. - * When users log in via the /login page, it posts to this endpoint - * which sets the session cookie needed for the OAuth2 flow. - */ - @Post('api/auth/sign-in/email') - async signInEmail(@Req() req: Request, @Res() res: Response) { - return this.handleBetterAuthRequest(req, res); - } - - /** - * Handle Better Auth requests by forwarding to Better Auth's handler - * This is a simpler handler that just passes through to Better Auth - */ - private async handleBetterAuthRequest(req: Request, res: Response) { - try { - const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; - const url = new URL(req.originalUrl, baseUrl); - - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value && typeof value === 'string') { - headers.set(key, value); - } else if (Array.isArray(value)) { - headers.set(key, value[0]); - } - } - - // Create Fetch Request - const fetchRequest = new Request(url.toString(), { - method: req.method, - headers, - body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, - }); - - // Get Better Auth handler and call it directly - const handler = this.betterAuthService.getHandler(); - const response = await handler(fetchRequest); - - // Copy status - res.status(response.status); - - // Copy headers including Set-Cookie for session - response.headers.forEach((value: string, key: string) => { - // Handle multiple Set-Cookie headers - if (key.toLowerCase() === 'set-cookie') { - res.append(key, value); - } else { - res.setHeader(key, value); - } - }); - - // Handle redirects - if (response.status === 302 || response.status === 301) { - const location = response.headers.get('location'); - if (location) { - return res.redirect(response.status, location); - } - } - - // Return body - const body = await response.text(); - if (body) { - return res.send(body); - } - - return res.end(); - } catch (error) { - this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined); - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ - error: 'server_error', - error_description: 'Internal server error', - }); - } - } - - // ============================================ - // Alternative /api/oidc/* paths - // For backwards compatibility and convenience - // ============================================ - - /** - * Authorization Endpoint (alternative path) - */ - @Get('api/oidc/authorize') - async authorize(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * Token Endpoint (alternative path) - */ - @Post('api/oidc/token') - async token(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * UserInfo Endpoint (alternative path) - * - * When Matrix/Synapse calls this endpoint, we automatically create - * the Matrix user link so bots can recognize the user. - */ - @Get('api/oidc/userinfo') - async userinfo(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequestWithMatrixLink(req, res); - } - - /** - * JWKS Endpoint (alternative path) - */ - @Get('api/oidc/jwks') - async jwks(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * Catch-all for other OIDC endpoints (alternative path) - */ - @All('api/oidc/*') - async catchAll(@Req() req: Request, @Res() res: Response) { - return this.handleOidcRequest(req, res); - } - - /** - * Handle OIDC request by forwarding to Better Auth - */ - private async handleOidcRequest(req: Request, res: Response) { - try { - const response = await this.betterAuthService.handleOidcRequest(req); - - // Set status code - res.status(response.status || HttpStatus.OK); - - // Copy headers from Better Auth response - if (response.headers) { - for (const [key, value] of Object.entries(response.headers)) { - if (value) { - res.setHeader(key, value as string); - } - } - } - - // Handle redirects - if (response.status === 302 || response.status === 301) { - const location = response.headers?.location || response.headers?.Location; - if (location) { - return res.redirect(response.status, location as string); - } - } - - // Return body - if (response.body) { - return res.send(response.body); - } - - return res.end(); - } catch (error) { - this.logger.error( - 'OIDC alternative path request failed', - error instanceof Error ? error.stack : undefined - ); - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ - error: 'server_error', - error_description: 'Internal server error', - }); - } - } - - /** - * Handle OIDC userinfo request with automatic Matrix user linking - * - * This method forwards the request to Better Auth, and if successful, - * automatically creates a Matrix user link so bots can recognize - * the user without requiring a separate !login command. - */ - private async handleOidcRequestWithMatrixLink(req: Request, res: Response) { - try { - const response = await this.betterAuthService.handleOidcRequest(req); - - // Set status code - res.status(response.status || HttpStatus.OK); - - // Copy headers from Better Auth response - if (response.headers) { - for (const [key, value] of Object.entries(response.headers)) { - if (value) { - res.setHeader(key, value as string); - } - } - } - - // If userinfo was successful, create the Matrix user link - if (response.status === 200 && response.body) { - try { - const userInfo = response.body as { sub?: string; email?: string }; - if (userInfo.sub && userInfo.email) { - // Create Matrix user link asynchronously (don't block the response) - this.matrixSessionService - .autoLinkOnOidcLogin(userInfo.sub, userInfo.email) - .catch((err) => { - this.logger.warn('Failed to auto-link Matrix user on OIDC login', { - error: err instanceof Error ? err.message : 'Unknown error', - }); - }); - } - } catch (linkError) { - // Log but don't fail the request - this.logger.warn('Error parsing userinfo for Matrix link', { - error: linkError instanceof Error ? linkError.message : 'Unknown error', - }); - } - } - - // Return body - if (response.body) { - return res.send(response.body); - } - - return res.end(); - } catch (error) { - this.logger.error( - 'OIDC userinfo request failed', - error instanceof Error ? error.stack : undefined - ); - return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ - error: 'server_error', - error_description: 'Internal server error', - }); - } - } -} diff --git a/services/mana-core-auth/src/auth/passkey-controller.spec.ts b/services/mana-core-auth/src/auth/passkey-controller.spec.ts deleted file mode 100644 index 109200186..000000000 --- a/services/mana-core-auth/src/auth/passkey-controller.spec.ts +++ /dev/null @@ -1,480 +0,0 @@ -/** - * AuthController Passkey + 2FA Unit Tests - * - * Tests all passkey (WebAuthn) endpoints on the AuthController: - * - * - POST /auth/passkeys/register/options - Generate registration options - * - POST /auth/passkeys/register/verify - Verify and store passkey - * - POST /auth/passkeys/authenticate/options - Generate auth options (public) - * - POST /auth/passkeys/authenticate/verify - Verify and return JWT tokens - * - GET /auth/passkeys - List user's passkeys - * - DELETE /auth/passkeys/:id - Delete a passkey - * - PATCH /auth/passkeys/:id - Rename a passkey - * - * Also tests 2FA-related behavior in signIn. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { AuthController } from './auth.controller'; -import { BetterAuthService } from './services/better-auth.service'; -import { PasskeyService } from './services/passkey.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; - -describe('AuthController - Passkey Endpoints', () => { - let controller: AuthController; - let passkeyService: jest.Mocked; - let betterAuthService: jest.Mocked; - let securityEventsService: jest.Mocked; - - const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' }; - const mockReq = { - headers: { 'user-agent': 'test-agent' }, - ip: '127.0.0.1', - } as any; - - beforeEach(async () => { - const mockPasskeyService = { - generateRegistrationOptions: jest.fn(), - verifyRegistration: jest.fn(), - generateAuthenticationOptions: jest.fn(), - verifyAuthentication: jest.fn(), - listPasskeys: jest.fn(), - deletePasskey: jest.fn(), - renamePasskey: jest.fn(), - }; - - const mockBetterAuthService = { - registerB2C: jest.fn(), - registerB2B: jest.fn(), - signIn: jest.fn(), - signOut: jest.fn(), - getSession: jest.fn(), - listOrganizations: jest.fn(), - getOrganization: jest.fn(), - getOrganizationMembers: jest.fn(), - inviteEmployee: jest.fn(), - acceptInvitation: jest.fn(), - removeMember: jest.fn(), - setActiveOrganization: jest.fn(), - refreshToken: jest.fn(), - validateToken: jest.fn(), - createSessionAndTokens: jest.fn(), - requestPasswordReset: jest.fn(), - resetPassword: jest.fn(), - resendVerificationEmail: jest.fn(), - getProfile: jest.fn(), - updateProfile: jest.fn(), - changePassword: jest.fn(), - deleteAccount: jest.fn(), - sessionToToken: jest.fn(), - getJwks: jest.fn(), - updateOrganization: jest.fn(), - deleteOrganization: jest.fn(), - updateMemberRole: jest.fn(), - listOrganizationInvitations: jest.fn(), - listUserInvitations: jest.fn(), - cancelInvitation: jest.fn(), - rejectInvitation: jest.fn(), - }; - - const mockSecurityEventsService = { - logEvent: jest.fn().mockResolvedValue(undefined), - logEventWithRequest: jest.fn().mockResolvedValue(undefined), - extractRequestInfo: jest.fn().mockReturnValue({ - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - }), - }; - - const mockAccountLockoutService = { - checkLockout: jest.fn().mockResolvedValue({ locked: false }), - recordAttempt: jest.fn().mockResolvedValue(undefined), - clearAttempts: jest.fn().mockResolvedValue(undefined), - }; - - const module: TestingModule = await Test.createTestingModule({ - imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], - controllers: [AuthController], - providers: [ - { provide: BetterAuthService, useValue: mockBetterAuthService }, - { provide: PasskeyService, useValue: mockPasskeyService }, - { provide: SecurityEventsService, useValue: mockSecurityEventsService }, - { provide: AccountLockoutService, useValue: mockAccountLockoutService }, - ], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .overrideGuard(ThrottlerGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .compile(); - - controller = module.get(AuthController); - passkeyService = module.get(PasskeyService); - betterAuthService = module.get(BetterAuthService); - securityEventsService = module.get(SecurityEventsService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ============================================================================ - // POST /auth/passkeys/register/options - // ============================================================================ - - describe('POST /auth/passkeys/register/options', () => { - it('should call generateRegistrationOptions with user.userId', async () => { - const expectedResult = { - options: { - challenge: 'test-challenge', - rp: { name: 'ManaCore', id: 'localhost' }, - }, - challengeId: 'challenge-id-123', - }; - - passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any); - - const result = await controller.passkeyRegisterOptions(mockUser as any); - - expect(result).toEqual(expectedResult); - expect(passkeyService.generateRegistrationOptions).toHaveBeenCalledWith('user-123'); - }); - - it('should return options and challengeId', async () => { - const expectedResult = { - options: { challenge: 'abc', rp: { name: 'ManaCore', id: 'localhost' } }, - challengeId: 'ch-456', - }; - - passkeyService.generateRegistrationOptions.mockResolvedValue(expectedResult as any); - - const result = await controller.passkeyRegisterOptions(mockUser as any); - - expect(result.options).toBeDefined(); - expect(result.challengeId).toBe('ch-456'); - }); - }); - - // ============================================================================ - // POST /auth/passkeys/register/verify - // ============================================================================ - - describe('POST /auth/passkeys/register/verify', () => { - it('should verify and return passkey info', async () => { - const body = { - challengeId: 'challenge-123', - credential: { id: 'cred-1', response: {} }, - friendlyName: 'My Passkey', - }; - - const expectedResult = { - id: 'pk-123', - credentialId: 'cred-1', - deviceType: 'multiPlatform', - friendlyName: 'My Passkey', - createdAt: new Date(), - }; - - passkeyService.verifyRegistration.mockResolvedValue(expectedResult); - - const result = await controller.passkeyRegisterVerify(mockUser as any, body, mockReq); - - expect(result).toEqual(expectedResult); - expect(passkeyService.verifyRegistration).toHaveBeenCalledWith( - 'challenge-123', - body.credential, - 'My Passkey' - ); - }); - - it('should log security event on successful registration', async () => { - const body = { - challengeId: 'challenge-123', - credential: { id: 'cred-1', response: {} }, - }; - - passkeyService.verifyRegistration.mockResolvedValue({ - id: 'pk-123', - credentialId: 'cred-1', - deviceType: 'singleDevice', - friendlyName: null, - createdAt: new Date(), - }); - - await controller.passkeyRegisterVerify(mockUser as any, body, mockReq); - - expect(securityEventsService.logEvent).toHaveBeenCalledWith({ - userId: 'user-123', - eventType: SecurityEventType.PASSKEY_REGISTERED, - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - metadata: { passkeyId: 'pk-123' }, - }); - }); - }); - - // ============================================================================ - // POST /auth/passkeys/authenticate/options - // ============================================================================ - - describe('POST /auth/passkeys/authenticate/options', () => { - it('should return options (no auth required)', async () => { - const expectedResult = { - options: { challenge: 'auth-challenge', rpId: 'localhost' }, - challengeId: 'auth-ch-123', - }; - - passkeyService.generateAuthenticationOptions.mockResolvedValue(expectedResult); - - const result = await controller.passkeyAuthOptions(); - - expect(result).toEqual(expectedResult); - expect(passkeyService.generateAuthenticationOptions).toHaveBeenCalled(); - }); - }); - - // ============================================================================ - // POST /auth/passkeys/authenticate/verify - // ============================================================================ - - describe('POST /auth/passkeys/authenticate/verify', () => { - it('should verify, create session+tokens, return tokens', async () => { - const body = { - challengeId: 'auth-ch-123', - credential: { id: 'cred-1', response: {} }, - }; - - const mockAuthUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - emailVerified: true, - image: null, - createdAt: new Date(), - updatedAt: new Date(), - role: 'user' as const, - twoFactorEnabled: null, - deletedAt: null, - }; - - passkeyService.verifyAuthentication.mockResolvedValue({ - user: mockAuthUser as any, - passkeyId: 'pk-123', - }); - - const tokenResult = { - user: { id: 'user-123', email: 'test@example.com', name: 'Test User', role: 'user' }, - accessToken: 'jwt-access-token', - refreshToken: 'jwt-refresh-token', - expiresIn: 900, - }; - - betterAuthService.createSessionAndTokens.mockResolvedValue(tokenResult); - - const result = await controller.passkeyAuthVerify(body, mockReq); - - expect(result).toEqual(tokenResult); - expect(passkeyService.verifyAuthentication).toHaveBeenCalledWith( - 'auth-ch-123', - body.credential - ); - expect(betterAuthService.createSessionAndTokens).toHaveBeenCalledWith(mockAuthUser, { - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - }); - }); - - it('should log security event on success', async () => { - const body = { - challengeId: 'auth-ch-123', - credential: { id: 'cred-1', response: {} }, - }; - - passkeyService.verifyAuthentication.mockResolvedValue({ - user: { - id: 'user-123', - email: 'test@example.com', - name: 'Test', - emailVerified: true, - image: null, - createdAt: new Date(), - updatedAt: new Date(), - role: 'user' as const, - twoFactorEnabled: null, - deletedAt: null, - } as any, - passkeyId: 'pk-456', - }); - - betterAuthService.createSessionAndTokens.mockResolvedValue({ - user: { id: 'user-123', email: 'test@example.com', name: 'Test', role: 'user' }, - accessToken: 'token', - refreshToken: 'refresh', - expiresIn: 900, - }); - - await controller.passkeyAuthVerify(body, mockReq); - - expect(securityEventsService.logEvent).toHaveBeenCalledWith({ - userId: 'user-123', - eventType: SecurityEventType.PASSKEY_LOGIN_SUCCESS, - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - metadata: { passkeyId: 'pk-456' }, - }); - }); - }); - - // ============================================================================ - // GET /auth/passkeys - // ============================================================================ - - describe('GET /auth/passkeys', () => { - it("should return user's passkeys", async () => { - const mockPasskeys = [ - { - id: 'pk-1', - credentialId: 'cred-1', - deviceType: 'multiPlatform', - backedUp: true, - friendlyName: 'MacBook', - lastUsedAt: new Date(), - createdAt: new Date(), - }, - { - id: 'pk-2', - credentialId: 'cred-2', - deviceType: 'singleDevice', - backedUp: false, - friendlyName: null, - lastUsedAt: null, - createdAt: new Date(), - }, - ]; - - passkeyService.listPasskeys.mockResolvedValue(mockPasskeys); - - const result = await controller.listPasskeys(mockUser as any); - - expect(result).toEqual(mockPasskeys); - expect(passkeyService.listPasskeys).toHaveBeenCalledWith('user-123'); - }); - }); - - // ============================================================================ - // DELETE /auth/passkeys/:id - // ============================================================================ - - describe('DELETE /auth/passkeys/:id', () => { - it('should delete and log security event', async () => { - passkeyService.deletePasskey.mockResolvedValue(undefined); - - await controller.deletePasskey(mockUser as any, 'pk-123', mockReq); - - expect(passkeyService.deletePasskey).toHaveBeenCalledWith('user-123', 'pk-123'); - expect(securityEventsService.logEvent).toHaveBeenCalledWith({ - userId: 'user-123', - eventType: SecurityEventType.PASSKEY_DELETED, - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - metadata: { passkeyId: 'pk-123' }, - }); - }); - - it('should return void (204 status handled by decorator)', async () => { - passkeyService.deletePasskey.mockResolvedValue(undefined); - - const result = await controller.deletePasskey(mockUser as any, 'pk-456', mockReq); - - expect(result).toBeUndefined(); - }); - }); - - // ============================================================================ - // PATCH /auth/passkeys/:id - // ============================================================================ - - describe('PATCH /auth/passkeys/:id', () => { - it('should rename passkey', async () => { - passkeyService.renamePasskey.mockResolvedValue(undefined); - - const result = await controller.renamePasskey(mockUser as any, 'pk-123', { - friendlyName: 'Work Laptop', - }); - - expect(result).toEqual({ success: true }); - expect(passkeyService.renamePasskey).toHaveBeenCalledWith( - 'user-123', - 'pk-123', - 'Work Laptop' - ); - }); - }); - - // ============================================================================ - // 2FA behavior in signIn - // ============================================================================ - - describe('2FA in signIn', () => { - it('should pass through twoFactorRedirect when returned by BetterAuthService', async () => { - const loginDto = { - email: 'user@example.com', - password: 'SecurePassword123!', - deviceId: undefined, - deviceName: undefined, - }; - - const twoFactorResult = { - twoFactorRedirect: true, - message: 'Two-factor authentication required', - }; - - betterAuthService.signIn.mockResolvedValue(twoFactorResult as any); - - const result = await controller.login(loginDto, mockReq); - - expect(result).toEqual(twoFactorResult); - expect((result as any).twoFactorRedirect).toBe(true); - }); - }); - - // ============================================================================ - // Guard Tests for Passkey Endpoints - // ============================================================================ - - describe('Passkey Guard Configuration', () => { - it('should have JwtAuthGuard on protected passkey endpoints', () => { - const protectedEndpoints: (keyof AuthController)[] = [ - 'passkeyRegisterOptions', - 'passkeyRegisterVerify', - 'listPasskeys', - 'deletePasskey', - 'renamePasskey', - ]; - - protectedEndpoints.forEach((endpoint) => { - const guards = Reflect.getMetadata( - '__guards__', - AuthController.prototype[endpoint as keyof AuthController] - ); - expect(guards).toBeDefined(); - expect(guards).toContain(JwtAuthGuard); - }); - }); - - it('should NOT have JwtAuthGuard on public passkey endpoints', () => { - const publicEndpoints: (keyof AuthController)[] = ['passkeyAuthOptions', 'passkeyAuthVerify']; - - publicEndpoints.forEach((endpoint) => { - const guards = Reflect.getMetadata( - '__guards__', - AuthController.prototype[endpoint as keyof AuthController] - ); - expect(guards).toBeUndefined(); - }); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/security-events-controller.spec.ts b/services/mana-core-auth/src/auth/security-events-controller.spec.ts deleted file mode 100644 index e71ea4a32..000000000 --- a/services/mana-core-auth/src/auth/security-events-controller.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * AuthController Security Events Unit Tests - * - * Tests the security events / audit log endpoint on the AuthController: - * - * - GET /auth/security-events - List user's security events - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; -import { AuthController } from './auth.controller'; -import { BetterAuthService } from './services/better-auth.service'; -import { PasskeyService } from './services/passkey.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security'; - -describe('AuthController - Security Events', () => { - let controller: AuthController; - let betterAuthService: jest.Mocked; - - const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' }; - const mockReq = { - headers: { 'user-agent': 'test-agent' }, - ip: '127.0.0.1', - } as any; - - beforeEach(async () => { - const mockPasskeyService = { - generateRegistrationOptions: jest.fn(), - verifyRegistration: jest.fn(), - generateAuthenticationOptions: jest.fn(), - verifyAuthentication: jest.fn(), - listPasskeys: jest.fn(), - deletePasskey: jest.fn(), - renamePasskey: jest.fn(), - }; - - const mockBetterAuthService = { - registerB2C: jest.fn(), - registerB2B: jest.fn(), - signIn: jest.fn(), - signOut: jest.fn(), - getSession: jest.fn(), - listOrganizations: jest.fn(), - getOrganization: jest.fn(), - getOrganizationMembers: jest.fn(), - inviteEmployee: jest.fn(), - acceptInvitation: jest.fn(), - removeMember: jest.fn(), - setActiveOrganization: jest.fn(), - refreshToken: jest.fn(), - validateToken: jest.fn(), - createSessionAndTokens: jest.fn(), - requestPasswordReset: jest.fn(), - resetPassword: jest.fn(), - resendVerificationEmail: jest.fn(), - getProfile: jest.fn(), - updateProfile: jest.fn(), - changePassword: jest.fn(), - deleteAccount: jest.fn(), - sessionToToken: jest.fn(), - getJwks: jest.fn(), - updateOrganization: jest.fn(), - deleteOrganization: jest.fn(), - updateMemberRole: jest.fn(), - listOrganizationInvitations: jest.fn(), - listUserInvitations: jest.fn(), - cancelInvitation: jest.fn(), - rejectInvitation: jest.fn(), - getSecurityEvents: jest.fn(), - }; - - const mockSecurityEventsService = { - logEvent: jest.fn().mockResolvedValue(undefined), - logEventWithRequest: jest.fn().mockResolvedValue(undefined), - extractRequestInfo: jest.fn().mockReturnValue({ - ipAddress: '127.0.0.1', - userAgent: 'test-agent', - }), - }; - - const mockAccountLockoutService = { - checkLockout: jest.fn().mockResolvedValue({ locked: false }), - recordAttempt: jest.fn().mockResolvedValue(undefined), - clearAttempts: jest.fn().mockResolvedValue(undefined), - }; - - const module: TestingModule = await Test.createTestingModule({ - imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])], - controllers: [AuthController], - providers: [ - { provide: BetterAuthService, useValue: mockBetterAuthService }, - { provide: PasskeyService, useValue: mockPasskeyService }, - { provide: SecurityEventsService, useValue: mockSecurityEventsService }, - { provide: AccountLockoutService, useValue: mockAccountLockoutService }, - ], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .overrideGuard(ThrottlerGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .compile(); - - controller = module.get(AuthController); - betterAuthService = module.get(BetterAuthService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ============================================================================ - // GET /auth/security-events - // ============================================================================ - - describe('GET /auth/security-events', () => { - it("should return user's events from BetterAuthService", async () => { - const mockEvents = [ - { - id: 'evt-1', - eventType: 'login_success', - ipAddress: '192.168.1.1', - userAgent: 'Mozilla/5.0', - metadata: { email: 'test@example.com' }, - createdAt: new Date('2026-03-27T10:00:00Z'), - }, - { - id: 'evt-2', - eventType: 'password_changed', - ipAddress: '192.168.1.1', - userAgent: 'Mozilla/5.0', - metadata: {}, - createdAt: new Date('2026-03-26T09:00:00Z'), - }, - ]; - - betterAuthService.getSecurityEvents.mockResolvedValue(mockEvents); - - const result = await controller.getSecurityEvents(mockUser as any, mockReq); - - expect(result).toEqual(mockEvents); - expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123'); - }); - - it('should return empty array when no events exist', async () => { - betterAuthService.getSecurityEvents.mockResolvedValue([]); - - const result = await controller.getSecurityEvents(mockUser as any, mockReq); - - expect(result).toEqual([]); - expect(betterAuthService.getSecurityEvents).toHaveBeenCalledWith('user-123'); - }); - - it('should return events in descending order by createdAt', async () => { - const newerEvent = { - id: 'evt-1', - eventType: 'login_success', - ipAddress: '127.0.0.1', - userAgent: 'test', - metadata: {}, - createdAt: new Date('2026-03-27T12:00:00Z'), - }; - const olderEvent = { - id: 'evt-2', - eventType: 'logout', - ipAddress: '127.0.0.1', - userAgent: 'test', - metadata: {}, - createdAt: new Date('2026-03-26T08:00:00Z'), - }; - - // BetterAuthService already orders them desc by createdAt - betterAuthService.getSecurityEvents.mockResolvedValue([newerEvent, olderEvent]); - - const result = await controller.getSecurityEvents(mockUser as any, mockReq); - - expect(result).toHaveLength(2); - expect(new Date(result[0].createdAt).getTime()).toBeGreaterThan( - new Date(result[1].createdAt).getTime() - ); - }); - }); - - // ============================================================================ - // Guard Configuration - // ============================================================================ - - describe('Security Events Guard Configuration', () => { - it('should have JwtAuthGuard on getSecurityEvents', () => { - const guards = Reflect.getMetadata('__guards__', AuthController.prototype.getSecurityEvents); - expect(guards).toBeDefined(); - expect(guards).toContain(JwtAuthGuard); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/services/audit-log.spec.ts b/services/mana-core-auth/src/auth/services/audit-log.spec.ts deleted file mode 100644 index 3934e7892..000000000 --- a/services/mana-core-auth/src/auth/services/audit-log.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * BetterAuthService.getSecurityEvents Unit Tests - * - * Tests the audit log / security events query method. - * Uses the thenable DB mock pattern from passkey.service.spec.ts. - * - * Since BetterAuthService has complex constructor dependencies (Better Auth, - * OIDC provider), we mock the better-auth.config module and the DB connection. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { getDb } from '../../db/connection'; -import { LoggerService } from '../../common/logger'; - -// Mock better-auth config to avoid oidcProvider instantiation -jest.mock('../better-auth.config', () => ({ - createBetterAuth: jest.fn(() => ({ - api: {}, - handler: jest.fn(), - })), -})); - -jest.mock('../../db/connection', () => ({ - getDb: jest.fn(), -})); - -const createMockDb = () => { - let results: any[] = []; - let resultIndex = 0; - - const db: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - then: jest.fn((resolve) => resolve(results[resultIndex++] || [])), - setResults: (...r: any[]) => { - results = r; - resultIndex = 0; - }, - }; - return db; -}; - -// Import after mocks are set up -import { BetterAuthService } from './better-auth.service'; - -describe('BetterAuthService - getSecurityEvents', () => { - let service: BetterAuthService; - let mockDb: ReturnType; - - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: string) => { - const config: Record = { - 'database.url': 'postgresql://test:test@localhost:5432/test', - DATABASE_URL: 'postgresql://test:test@localhost:5432/test', - JWT_ISSUER: 'manacore', - JWT_AUDIENCE: 'manacore', - BASE_URL: 'http://localhost:3001', - }; - return config[key] || defaultValue || ''; - }), - }; - - const mockLoggerService = { - setContext: jest.fn().mockReturnThis(), - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }; - - beforeEach(async () => { - mockDb = createMockDb(); - (getDb as jest.Mock).mockReturnValue(mockDb); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - BetterAuthService, - { provide: ConfigService, useValue: mockConfigService }, - { provide: LoggerService, useValue: mockLoggerService }, - ], - }).compile(); - - service = module.get(BetterAuthService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should return events for a given userId ordered by createdAt desc', async () => { - const mockEvents = [ - { - id: 'evt-1', - eventType: 'login_success', - ipAddress: '192.168.1.1', - userAgent: 'Mozilla/5.0', - metadata: { email: 'test@example.com' }, - createdAt: new Date('2026-03-27T10:00:00Z'), - }, - { - id: 'evt-2', - eventType: 'logout', - ipAddress: '192.168.1.1', - userAgent: 'Mozilla/5.0', - metadata: {}, - createdAt: new Date('2026-03-26T09:00:00Z'), - }, - ]; - - mockDb.setResults(mockEvents); - - const result = await service.getSecurityEvents('user-123'); - - expect(result).toEqual(mockEvents); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - expect(mockDb.orderBy).toHaveBeenCalled(); - expect(mockDb.limit).toHaveBeenCalled(); - }); - - it('should limit results to default of 50', async () => { - mockDb.setResults([]); - - await service.getSecurityEvents('user-123'); - - expect(mockDb.limit).toHaveBeenCalledWith(50); - }); - - it('should respect custom limit parameter', async () => { - mockDb.setResults([]); - - await service.getSecurityEvents('user-123', 10); - - expect(mockDb.limit).toHaveBeenCalledWith(10); - }); - - it('should return empty array when no events exist', async () => { - mockDb.setResults([]); - - const result = await service.getSecurityEvents('user-123'); - - expect(result).toEqual([]); - }); -}); diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts b/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts deleted file mode 100644 index d1b95b2eb..000000000 --- a/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts +++ /dev/null @@ -1,980 +0,0 @@ -/** - * BetterAuthService Unit Tests - * - * Tests all Better Auth integration flows: - * - B2C user registration - * - B2B organization registration - * - Organization member management - * - Employee invitations - * - Credit balance initialization - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { ConflictException, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { BetterAuthService } from './better-auth.service'; -import { LoggerService } from '../../common/logger'; -import { createMockConfigService } from '../../__tests__/utils/test-helpers'; -import { silentError } from '../../__tests__/utils/silent-error.decorator'; - -// Mock services that are not yet implemented -const SecurityEventsService = jest.fn(); - -// Mock nanoid before importing factories -jest.mock('nanoid', () => ({ - nanoid: jest.fn(() => 'mock-nanoid-123'), -})); - -// Mock database connection -jest.mock('../../db/connection'); - -// Import after mocks -import { mockUserFactory } from '../../__tests__/utils/mock-factories'; - -// Mock Better Auth configuration -const mockAuthApi = { - signUpEmail: jest.fn(), - createOrganization: jest.fn(), - inviteMember: jest.fn(), - acceptInvitation: jest.fn(), - getFullOrganization: jest.fn(), - removeMember: jest.fn(), - setActiveOrganization: jest.fn(), -}; - -jest.mock('../better-auth.config', () => ({ - createBetterAuth: jest.fn(() => ({ - api: mockAuthApi, - })), -})); - -// Mock services -const mockSecurityEventsService = { - logEvent: jest.fn().mockResolvedValue(undefined), -}; - -const mockLoggerService = { - setContext: jest.fn().mockReturnThis(), - log: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - verbose: jest.fn(), -}; - -describe('BetterAuthService', () => { - let service: BetterAuthService; - let configService: ConfigService; - let mockDb: any; - - beforeEach(async () => { - // Create mock database - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - }; - - // Mock getDb - const { getDb } = require('../../db/connection'); - getDb.mockReturnValue(mockDb); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - BetterAuthService, - { - provide: ConfigService, - useValue: createMockConfigService({ - 'database.url': 'postgresql://test:test@localhost:5432/test', - }), - }, - { - provide: SecurityEventsService, - useValue: mockSecurityEventsService, - }, - { - provide: LoggerService, - useValue: mockLoggerService, - }, - ], - }).compile(); - - service = module.get(BetterAuthService); - configService = module.get(ConfigService); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - describe('registerB2C', () => { - it('should register a new B2C user successfully', async () => { - const registerDto = { - email: 'newuser@example.com', - password: 'SecurePassword123!', - name: 'New User', - }; - - const mockUser = mockUserFactory.create({ - id: 'user-123', - email: registerDto.email, - name: registerDto.name, - }); - - // Mock Better Auth signup response - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'mock-session-token', - }); - - // Mock credit balance creation (success) - mockDb.returning.mockResolvedValue([]); - - const result = await service.registerB2C(registerDto); - - // Verify Better Auth API was called correctly - expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({ - body: { - email: registerDto.email, - password: registerDto.password, - name: registerDto.name, - }, - }); - - // Verify personal credit balance was created (no free credits) - expect(mockDb.insert).toHaveBeenCalled(); - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-123', - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - ); - - // Verify response structure - expect(result).toEqual({ - user: { - id: 'user-123', - email: 'newuser@example.com', - name: 'New User', - }, - token: 'mock-session-token', - }); - }); - - it('should throw ConflictException if user already exists', async () => { - const registerDto = { - email: 'existing@example.com', - password: 'SecurePassword123!', - name: 'Existing User', - }; - - // Mock Better Auth error for existing user - mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists')); - - await expect(service.registerB2C(registerDto)).rejects.toThrow(ConflictException); - await expect(service.registerB2C(registerDto)).rejects.toThrow( - 'User with this email already exists' - ); - - // Verify no credit balance was created - expect(mockDb.insert).not.toHaveBeenCalled(); - }); - - it('should normalize email to lowercase', async () => { - const registerDto = { - email: 'NewUser@EXAMPLE.COM', - password: 'SecurePassword123!', - name: 'New User', - }; - - const mockUser = mockUserFactory.create({ - email: 'NewUser@EXAMPLE.COM', // Better Auth should handle normalization - }); - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'mock-token', - }); - - mockDb.returning.mockResolvedValue([]); - - await service.registerB2C(registerDto); - - // Verify email was passed as-is (Better Auth normalizes internally) - expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({ - body: expect.objectContaining({ - email: 'NewUser@EXAMPLE.COM', - }), - }); - }); - - it('should create personal credit balance with signup bonus', async () => { - const registerDto = { - email: 'test@example.com', - password: 'SecurePassword123!', - name: 'Test User', - }; - - const mockUser = mockUserFactory.create({ id: 'user-123' }); - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'mock-token', - }); - - mockDb.returning.mockResolvedValue([]); - - await service.registerB2C(registerDto); - - // Verify credit balance initialization (no free credits) - expect(mockDb.values).toHaveBeenCalledWith({ - userId: 'user-123', - balance: 0, - totalEarned: 0, - totalSpent: 0, - }); - }); - - it('should continue registration even if credit balance creation fails', async () => { - const registerDto = { - email: 'test@example.com', - password: 'SecurePassword123!', - name: 'Test User', - }; - - const mockUser = mockUserFactory.create({ id: 'user-123' }); - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'mock-token', - }); - - // Mock database error for credit balance creation - mockDb.returning.mockRejectedValue(new Error('Database error')); - - // Should not throw - registration should complete - const result = await service.registerB2C(registerDto); - - expect(result.user.id).toBe('user-123'); - }); - }); - - describe('registerB2B', () => { - it('should register organization owner successfully', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const mockUser = mockUserFactory.create({ - id: 'owner-123', - email: registerDto.ownerEmail, - name: registerDto.ownerName, - }); - - const mockOrg = { - id: 'org-123', - name: 'Acme Corporation', - slug: 'acme-corporation', - }; - - // Mock user creation - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'mock-session-token', - }); - - // Mock organization creation - mockAuthApi.createOrganization.mockResolvedValue(mockOrg); - - // Mock credit balance creation - mockDb.returning.mockResolvedValue([]); - - const result = await service.registerB2B(registerDto); - - // Verify user creation - expect(mockAuthApi.signUpEmail).toHaveBeenCalledWith({ - body: { - email: registerDto.ownerEmail, - password: registerDto.password, - name: registerDto.ownerName, - }, - }); - - // Verify organization creation - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: { - name: 'Acme Corporation', - slug: 'acme-corporation', - }, - headers: { - authorization: 'Bearer mock-session-token', - }, - }); - - // Verify personal credit balance was created (org balance removed) - expect(mockDb.insert).toHaveBeenCalledTimes(1); - - // Verify response structure - expect(result).toEqual({ - user: mockUser, - organization: mockOrg, - token: 'mock-session-token', - }); - }); - - it('should create personal credit balance for org owner', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - const mockOrg = { id: 'org-123', name: 'Acme Corporation' }; - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'token', - }); - mockAuthApi.createOrganization.mockResolvedValue(mockOrg); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - // Verify personal credit balance was created (no org balance - B2B simplified) - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'owner-123', - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - ); - }); - - it('should handle organization creation failure', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'token', - }); - - // Mock organization creation failure - mockAuthApi.createOrganization.mockRejectedValue(new Error('Failed to create organization')); - - await expect(service.registerB2B(registerDto)).rejects.toThrow( - 'Failed to create organization' - ); - }); - - it('should generate valid slug from organization name', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'My Awesome Company!!!', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - const mockOrg = { id: 'org-123', name: 'My Awesome Company' }; - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'token', - }); - mockAuthApi.createOrganization.mockResolvedValue(mockOrg); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - // Verify slug was sanitized - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: expect.objectContaining({ - slug: 'my-awesome-company', - }), - headers: expect.anything(), - }); - }); - - it('should throw ConflictException if owner email already exists', async () => { - const registerDto = { - ownerEmail: 'existing@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - mockAuthApi.signUpEmail.mockRejectedValue(new Error('User with this email already exists')); - - await expect(service.registerB2B(registerDto)).rejects.toThrow(ConflictException); - await expect(service.registerB2B(registerDto)).rejects.toThrow('Owner email already exists'); - - // Verify organization was never created - expect(mockAuthApi.createOrganization).not.toHaveBeenCalled(); - }); - - it('should create both organization and personal credit balances', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - const mockOrg = { id: 'org-123', name: 'Acme Corporation' }; - - mockAuthApi.signUpEmail.mockResolvedValue({ - user: mockUser, - token: 'token', - }); - mockAuthApi.createOrganization.mockResolvedValue(mockOrg); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - // Verify personal credit balance was created (no org balance) - expect(mockDb.insert).toHaveBeenCalledTimes(1); - - // Personal balance for the owner - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'owner-123', - balance: 0, - }) - ); - }); - }); - - describe('inviteEmployee', () => { - it('should send invitation successfully', async () => { - const inviteDto = { - organizationId: 'org-123', - employeeEmail: 'employee@example.com', - role: 'member' as const, - inviterToken: 'inviter-session-token', - }; - - const mockInvitation = { - id: 'invitation-123', - email: 'employee@example.com', - organizationId: 'org-123', - role: 'member', - }; - - mockAuthApi.inviteMember.mockResolvedValue(mockInvitation); - - const result = await service.inviteEmployee(inviteDto); - - // Verify Better Auth API was called - expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({ - body: { - organizationId: 'org-123', - email: 'employee@example.com', - role: 'member', - }, - headers: { - authorization: 'Bearer inviter-session-token', - }, - }); - - expect(result).toEqual(mockInvitation); - }); - - it('should pass correct role to Better Auth API', async () => { - const inviteDto = { - organizationId: 'org-123', - employeeEmail: 'admin@example.com', - role: 'admin' as const, - inviterToken: 'inviter-token', - }; - - mockAuthApi.inviteMember.mockResolvedValue({}); - - await service.inviteEmployee(inviteDto); - - expect(mockAuthApi.inviteMember).toHaveBeenCalledWith({ - body: expect.objectContaining({ - role: 'admin', - }), - headers: expect.anything(), - }); - }); - - it('should handle invitation to existing member', async () => { - const inviteDto = { - organizationId: 'org-123', - employeeEmail: 'existing@example.com', - role: 'member' as const, - inviterToken: 'inviter-token', - }; - - mockAuthApi.inviteMember.mockRejectedValue(new Error('User is already a member')); - - await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('User is already a member'); - }); - - it('should throw ForbiddenException if inviter lacks permission', async () => { - const inviteDto = { - organizationId: 'org-123', - employeeEmail: 'employee@example.com', - role: 'member' as const, - inviterToken: 'invalid-token', - }; - - mockAuthApi.inviteMember.mockRejectedValue( - new Error('You do not have permission to invite members') - ); - - await expect(service.inviteEmployee(inviteDto)).rejects.toThrow(ForbiddenException); - await expect(service.inviteEmployee(inviteDto)).rejects.toThrow( - 'You do not have permission to invite members' - ); - }); - }); - - describe('acceptInvitation', () => { - it('should accept invitation and add user to org', async () => { - const acceptDto = { - invitationId: 'invitation-123', - userToken: 'user-session-token', - }; - - const mockMembership = { - userId: 'user-123', - organizationId: 'org-123', - role: 'member', - }; - - mockAuthApi.acceptInvitation.mockResolvedValue(mockMembership); - - const result = await service.acceptInvitation(acceptDto); - - // Verify Better Auth API was called - expect(mockAuthApi.acceptInvitation).toHaveBeenCalledWith({ - body: { invitationId: 'invitation-123' }, - headers: { - authorization: 'Bearer user-session-token', - }, - }); - - expect(result).toEqual(mockMembership); - }); - - it('should handle expired invitation', async () => { - const acceptDto = { - invitationId: 'expired-invitation', - userToken: 'user-token', - }; - - mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation expired')); - - await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException); - await expect(service.acceptInvitation(acceptDto)).rejects.toThrow( - 'Invitation not found or expired' - ); - }); - - it('should handle already accepted invitation', async () => { - const acceptDto = { - invitationId: 'used-invitation', - userToken: 'user-token', - }; - - mockAuthApi.acceptInvitation.mockRejectedValue(new Error('Invitation not found')); - - await expect(service.acceptInvitation(acceptDto)).rejects.toThrow(NotFoundException); - }); - }); - - describe('getOrganizationMembers', () => { - it('should return list of members', async () => { - const mockMembers = [ - { - userId: 'user-1', - organizationId: 'org-123', - role: 'owner', - name: 'John Owner', - email: 'owner@example.com', - }, - { - userId: 'user-2', - organizationId: 'org-123', - role: 'member', - name: 'Jane Member', - email: 'member@example.com', - }, - ]; - - mockAuthApi.getFullOrganization.mockResolvedValue({ members: mockMembers }); - - const result = await service.getOrganizationMembers('org-123'); - - expect(mockAuthApi.getFullOrganization).toHaveBeenCalledWith({ - query: { organizationId: 'org-123' }, - }); - - expect(result).toEqual(mockMembers); - expect(result).toHaveLength(2); - }); - - it('should handle empty organization', async () => { - mockAuthApi.getFullOrganization.mockResolvedValue({ members: [] }); - - const result = await service.getOrganizationMembers('org-123'); - - expect(result).toEqual([]); - }); - - it('should return empty array on error', async () => { - mockAuthApi.getFullOrganization.mockRejectedValue(new Error('Database error')); - - const result = await silentError(async () => { - return await service.getOrganizationMembers('org-123'); - }); - - // Should not throw, but return empty array - expect(result).toEqual([]); - }); - }); - - describe('removeMember', () => { - it('should remove member successfully', async () => { - const removeDto = { - organizationId: 'org-123', - memberId: 'user-456', - removerToken: 'admin-token', - }; - - mockAuthApi.removeMember.mockResolvedValue({ success: true }); - - const result = await service.removeMember(removeDto); - - expect(mockAuthApi.removeMember).toHaveBeenCalledWith({ - body: { - memberIdOrEmail: 'user-456', - organizationId: 'org-123', - }, - headers: { - authorization: 'Bearer admin-token', - }, - }); - - expect(result).toEqual({ - success: true, - message: 'Member removed successfully', - }); - }); - - it('should handle removing non-existent member', async () => { - const removeDto = { - organizationId: 'org-123', - memberId: 'non-existent', - removerToken: 'admin-token', - }; - - mockAuthApi.removeMember.mockRejectedValue(new Error('Member not found')); - - await expect(service.removeMember(removeDto)).rejects.toThrow('Member not found'); - }); - - it('should throw ForbiddenException if remover lacks permission', async () => { - const removeDto = { - organizationId: 'org-123', - memberId: 'user-456', - removerToken: 'member-token', // Regular member cannot remove - }; - - mockAuthApi.removeMember.mockRejectedValue( - new Error('You do not have permission to remove members') - ); - - await expect(service.removeMember(removeDto)).rejects.toThrow(ForbiddenException); - await expect(service.removeMember(removeDto)).rejects.toThrow( - 'You do not have permission to remove members' - ); - }); - }); - - describe('setActiveOrganization', () => { - it('should switch organization successfully', async () => { - const setActiveDto = { - organizationId: 'org-456', - userToken: 'user-token', - }; - - const mockSession = { - userId: 'user-123', - activeOrganizationId: 'org-456', - }; - - mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession); - - const result = await service.setActiveOrganization(setActiveDto); - - expect(mockAuthApi.setActiveOrganization).toHaveBeenCalledWith({ - body: { organizationId: 'org-456' }, - headers: { - authorization: 'Bearer user-token', - }, - }); - - expect(result).toEqual(mockSession); - }); - - it('should update session context', async () => { - const setActiveDto = { - organizationId: 'org-789', - userToken: 'user-token', - }; - - const mockSession = { - userId: 'user-123', - activeOrganizationId: 'org-789', - metadata: { - previousOrg: 'org-456', - }, - }; - - mockAuthApi.setActiveOrganization.mockResolvedValue(mockSession); - - const result = await service.setActiveOrganization(setActiveDto); - - expect(result.activeOrganizationId).toBe('org-789'); - }); - - it('should throw NotFoundException for invalid organization', async () => { - const setActiveDto = { - organizationId: 'non-existent-org', - userToken: 'user-token', - }; - - mockAuthApi.setActiveOrganization.mockRejectedValue( - new Error('Organization not found or you are not a member') - ); - - await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow(NotFoundException); - await expect(service.setActiveOrganization(setActiveDto)).rejects.toThrow( - 'Organization not found or you are not a member' - ); - }); - }); - - describe('slugify (private method)', () => { - it('should convert organization name to lowercase slug', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'My Company', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' }); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: expect.objectContaining({ - slug: 'my-company', - }), - headers: expect.anything(), - }); - }); - - it('should remove special characters from slug', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Company #1 (Best!)', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' }); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: expect.objectContaining({ - slug: 'company-1-best', - }), - headers: expect.anything(), - }); - }); - - it('should replace spaces with hyphens', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Multi Word Company Name', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' }); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: expect.objectContaining({ - slug: 'multi-word-company-name', - }), - headers: expect.anything(), - }); - }); - - it('should handle multiple consecutive spaces', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Company With Spaces', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockAuthApi.createOrganization.mockResolvedValue({ id: 'org-123' }); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - expect(mockAuthApi.createOrganization).toHaveBeenCalledWith({ - body: expect.objectContaining({ - slug: 'company-with-spaces', - }), - headers: expect.anything(), - }); - }); - }); - - describe('Credit Balance Initialization', () => { - it('should initialize B2C user with signup bonus credits', async () => { - const registerDto = { - email: 'test@example.com', - password: 'SecurePassword123!', - name: 'Test User', - }; - - const mockUser = mockUserFactory.create({ id: 'user-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2C(registerDto); - - // Verify credit balance was initialized with correct values (simplified - no free credits) - expect(mockDb.values).toHaveBeenCalledWith({ - userId: 'user-123', - balance: 0, - totalEarned: 0, - totalSpent: 0, - }); - }); - - it('should initialize personal credit balance for B2B owner', async () => { - const registerDto = { - ownerEmail: 'owner@company.com', - password: 'SecurePassword123!', - ownerName: 'John Owner', - organizationName: 'Acme Corporation', - }; - - const mockUser = mockUserFactory.create({ id: 'owner-123' }); - const mockOrg = { id: 'org-123', name: 'Acme Corporation' }; - - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - mockAuthApi.createOrganization.mockResolvedValue(mockOrg); - mockDb.returning.mockResolvedValue([]); - - await service.registerB2B(registerDto); - - // Verify personal balance was initialized (no org balance - simplified system) - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'owner-123', - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - ); - }); - - it('should not fail registration if credit balance creation errors', async () => { - const registerDto = { - email: 'test@example.com', - password: 'SecurePassword123!', - name: 'Test User', - }; - - const mockUser = mockUserFactory.create({ id: 'user-123' }); - mockAuthApi.signUpEmail.mockResolvedValue({ user: mockUser, token: 'token' }); - - // Mock database error - mockDb.insert.mockImplementation(() => { - throw new Error('Database connection failed'); - }); - - // Should not throw - registration should complete despite credit error - const result = await silentError(async () => { - return await service.registerB2C(registerDto); - }); - - expect(result.user.id).toBe('user-123'); - }); - }); - - describe('Error Handling', () => { - it('should handle generic errors from Better Auth', async () => { - const registerDto = { - email: 'test@example.com', - password: 'SecurePassword123!', - name: 'Test User', - }; - - mockAuthApi.signUpEmail.mockRejectedValue(new Error('Unexpected server error')); - - await expect(service.registerB2C(registerDto)).rejects.toThrow('Unexpected server error'); - }); - - it('should propagate network errors', async () => { - const inviteDto = { - organizationId: 'org-123', - employeeEmail: 'employee@example.com', - role: 'member' as const, - inviterToken: 'token', - }; - - mockAuthApi.inviteMember.mockRejectedValue(new Error('Network timeout')); - - await expect(service.inviteEmployee(inviteDto)).rejects.toThrow('Network timeout'); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts deleted file mode 100644 index 778c6e160..000000000 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ /dev/null @@ -1,2245 +0,0 @@ -/** - * Better Auth Service - * - * NestJS service that wraps Better Auth functionality for: - * - B2C user registration - * - B2B organization registration - * - Organization member management - * - Employee invitations - * - * This service uses Better Auth's organization plugin for all B2B operations, - * eliminating the need to build custom organization management. - * - * @see BETTER_AUTH_FINAL_PLAN.md - */ - -import { - Injectable, - ConflictException, - NotFoundException, - ForbiddenException, - UnauthorizedException, -} from '@nestjs/common'; -import { LoggerService } from '../../common/logger'; -import { ConfigService } from '@nestjs/config'; -import { createBetterAuth } from '../better-auth.config'; -import type { BetterAuthInstance } from '../better-auth.config'; -import { getDb } from '../../db/connection'; -import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types'; -import { sourceAppStore } from '../stores/source-app.store'; -import { passwordResetRedirectStore } from '../stores/password-reset-redirect.store'; -import type { - RegisterB2CDto, - RegisterB2BDto, - InviteEmployeeDto, - AcceptInvitationDto, - RemoveMemberDto, - SetActiveOrganizationDto, - SignInDto, - RegisterB2CResult, - RegisterB2BResult, - InviteEmployeeResult, - AcceptInvitationResult, - RemoveMemberResult, - SetActiveOrganizationResult, - SignInResult, - SignOutResult, - GetSessionResult, - ListOrganizationsResult, - RefreshTokenResult, - ValidateTokenResult, - TokenPayload, - OrganizationMember, - OrganizationInvitation, - Organization, - BetterAuthAPI, - SignUpResponse, - SignInResponse, - CreateOrganizationResponse, - BetterAuthUser, - BetterAuthSession, -} from '../types/better-auth.types'; -import { jwtVerify } from 'jose'; -import * as jwt from 'jsonwebtoken'; -import { createCachedLocalJWKSet } from '../../common/guards/local-jwks-cache'; - -// Re-export DTOs and result types for external use -export type { - RegisterB2CDto, - RegisterB2BDto, - InviteEmployeeDto, - AcceptInvitationDto, - RemoveMemberDto, - SetActiveOrganizationDto, - SignInDto, - SignInResult, - SignOutResult, - GetSessionResult, - ListOrganizationsResult, - RefreshTokenResult, - ValidateTokenResult, - TokenPayload, -}; - -@Injectable() -export class BetterAuthService { - private auth: BetterAuthInstance; - private databaseUrl: string; - private readonly logger: LoggerService; - - /** - * Typed accessor for organization plugin API methods - * Better Auth's organization plugin adds methods dynamically, so we provide - * a typed accessor to avoid casting throughout the service. - */ - private get orgApi(): BetterAuthAPI { - return this.auth.api as unknown as BetterAuthAPI; - } - - /** - * Get the Better Auth handler for processing requests - * Used by controllers that need to forward requests to Better Auth - */ - getHandler() { - return this.auth.handler; - } - - constructor( - private configService: ConfigService, - loggerService: LoggerService - ) { - this.logger = loggerService.setContext('BetterAuthService'); - this.databaseUrl = this.configService.get('database.url')!; - this.auth = createBetterAuth(this.databaseUrl); - } - - /** - * Register a B2C user (individual) - * - * Creates a new user account with email/password and initializes their - * personal credit balance. - * - * @param dto - Registration data - * @returns User data and session - * @throws ConflictException if email already exists - */ - async registerB2C(dto: RegisterB2CDto): Promise { - try { - // Store source app URL before registration (for email verification redirect) - if (dto.sourceAppUrl) { - sourceAppStore.set(dto.email, dto.sourceAppUrl); - } - - // Create user via Better Auth - const result = await this.auth.api.signUpEmail({ - body: { - email: dto.email, - password: dto.password, - name: dto.name, - }, - }); - - // Use type guards for safe access - if (!hasUser(result)) { - throw new Error('Invalid response from Better Auth: missing user'); - } - - const { user } = result; - - // Create personal credit balance - await this.createPersonalCreditBalance(user.id); - - // Redeem any pending gift codes via mana-credits service - try { - const creditsUrl = process.env.MANA_CREDITS_URL || 'http://localhost:3061'; - const serviceKey = process.env.MANA_CORE_SERVICE_KEY || ''; - const giftRes = await fetch(`${creditsUrl}/api/v1/internal/gifts/redeem-pending`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Service-Key': serviceKey }, - body: JSON.stringify({ userId: user.id, email: dto.email }), - }); - if (giftRes.ok) { - const giftResult = await giftRes.json(); - if (giftResult.redeemed > 0) { - this.logger.log('Redeemed pending gifts on registration', { - userId: user.id, - redeemedCount: giftResult.redeemed, - totalCredits: giftResult.totalCredits, - }); - } - } - } catch (error) { - this.logger.warn('Failed to redeem pending gifts via mana-credits (non-critical)', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - - return { - user: { - id: user.id, - email: user.email, - name: user.name, - }, - token: hasToken(result) ? result.token : undefined, - }; - } catch (error: unknown) { - if (error instanceof Error && error.message?.includes('already exists')) { - throw new ConflictException('User with this email already exists'); - } - throw error; - } - } - - /** - * Register a B2B organization (company) - * - * Creates: - * 1. Owner user account - * 2. Organization (via Better Auth organization plugin) - * 3. Automatic owner membership (Better Auth handles this) - * 4. Organization credit balance - * - * @param dto - Organization registration data - * @returns User, organization, and session data - * @throws ConflictException if owner email already exists - */ - async registerB2B(dto: RegisterB2BDto): Promise { - try { - // Step 1: Create owner user account - const userResult = await this.auth.api.signUpEmail({ - body: { - email: dto.ownerEmail, - password: dto.password, - name: dto.ownerName, - }, - }); - - // Use type guards for safe access - if (!hasUser(userResult)) { - throw new Error('Invalid response from Better Auth: missing user'); - } - - const { user } = userResult; - const ownerId = user.id; - const sessionToken = hasToken(userResult) ? userResult.token : ''; - - // Step 2: Create organization (Better Auth handles owner membership automatically) - // Note: createOrganization is typed via BetterAuthAPI but we need to cast for org plugin methods - const orgResult = (await this.auth.api.createOrganization({ - body: { - name: dto.organizationName, - slug: this.slugify(dto.organizationName), - }, - headers: { - authorization: `Bearer ${sessionToken}`, - }, - })) as CreateOrganizationResponse; - - const organizationId = orgResult.id; - - // Step 3: Create owner's personal balance (for when they use credits) - await this.createPersonalCreditBalance(ownerId); - - // Step 4: Initialize guild pool for the organization - await this.initializeGuildPool(organizationId); - - return { - user, - organization: orgResult, - token: sessionToken, - }; - } catch (error: unknown) { - if (error instanceof Error && error.message?.includes('already exists')) { - throw new ConflictException('Owner email already exists'); - } - throw error; - } - } - - /** - * Create an organization directly (for guild creation). - * The authenticated user becomes the owner. - */ - async createOrganizationDirect( - token: string, - data: { name: string; slug?: string; logo?: string } - ): Promise { - const slug = data.slug || this.slugify(data.name); - - const orgResult = (await this.auth.api.createOrganization({ - body: { - name: data.name, - slug, - ...(data.logo && { logo: data.logo }), - }, - headers: { - authorization: `Bearer ${token}`, - }, - })) as CreateOrganizationResponse; - - return orgResult; - } - - /** - * Invite employee to organization - * - * Uses Better Auth organization plugin to: - * 1. Validate inviter has permission (owner/admin) - * 2. Create invitation record - * 3. Send invitation email - * - * @param dto - Invitation data - * @returns Invitation record - * @throws ForbiddenException if inviter lacks permission - */ - async inviteEmployee(dto: InviteEmployeeDto): Promise { - try { - // Better Auth organization plugin uses auth.api.inviteMember - // See: https://www.better-auth.com/docs/plugins/organization - const result = await this.orgApi.inviteMember({ - body: { - email: dto.employeeEmail, - role: dto.role, - organizationId: dto.organizationId, - }, - headers: { - authorization: `Bearer ${dto.inviterToken}`, - }, - }); - - return result; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('You do not have permission to invite members'); - } - } - throw error; - } - } - - /** - * Accept organization invitation - * - * When a user accepts an invitation, Better Auth: - * 1. Adds user to organization as member - * 2. Sets the role from invitation - * 3. Marks invitation as accepted - * - * After acceptance, we create the user's personal balance for tracking - * their allocated credits from the organization. - * - * @param dto - Acceptance data - * @returns Membership data - * @throws NotFoundException if invitation not found or expired - */ - async acceptInvitation(dto: AcceptInvitationDto): Promise { - try { - // Better Auth organization plugin uses auth.api.acceptInvitation - // See: https://www.better-auth.com/docs/plugins/organization - const result = await this.orgApi.acceptInvitation({ - body: { invitationId: dto.invitationId }, - headers: { - authorization: `Bearer ${dto.userToken}`, - }, - }); - - // Extract user ID from the result to create their personal balance - // Use type guard for safe access - const userId = hasMember(result) ? result.member.userId : undefined; - if (userId) { - await this.createPersonalCreditBalance(userId); - } - - return result; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found') || error.message?.includes('expired')) { - throw new NotFoundException('Invitation not found or expired'); - } - } - throw error; - } - } - - /** - * Get organization members - * - * Lists all members of an organization with their roles. - * Uses getFullOrganization which returns org details with members. - * - * @param organizationId - Organization ID - * @returns List of members - */ - async getOrganizationMembers(organizationId: string): Promise { - try { - // Better Auth uses getFullOrganization to get org with members - // See: https://www.better-auth.com/docs/plugins/organization - const result = await this.orgApi.getFullOrganization({ - query: { organizationId }, - }); - - // Use type guard for safe access - return hasMembers(result) ? result.members : []; - } catch (error) { - this.logger.error( - 'Failed to fetch organization members', - error instanceof Error ? error.stack : undefined - ); - return []; - } - } - - /** - * Remove member from organization - * - * Uses Better Auth to: - * 1. Validate remover has permission (owner/admin) - * 2. Remove member from organization - * 3. Clean up member's access - * - * @param dto - Remove member data - * @returns Success status - * @throws ForbiddenException if remover lacks permission - */ - async removeMember(dto: RemoveMemberDto): Promise { - try { - // Better Auth organization plugin uses auth.api.removeMember - // Accepts memberIdOrEmail parameter - // See: https://www.better-auth.com/docs/plugins/organization - await this.orgApi.removeMember({ - body: { - memberIdOrEmail: dto.memberId, - organizationId: dto.organizationId, - }, - headers: { - authorization: `Bearer ${dto.removerToken}`, - }, - }); - - return { success: true, message: 'Member removed successfully' }; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('You do not have permission to remove members'); - } - } - throw error; - } - } - - /** - * Set active organization for user - * - * For users who belong to multiple organizations, this switches - * the active organization context. The active organization is used - * for JWT claims and credit balance calculations. - * - * @param dto - Active organization data - * @returns Updated session data - */ - async setActiveOrganization(dto: SetActiveOrganizationDto): Promise { - try { - // Better Auth organization plugin uses auth.api.setActiveOrganization - // See: https://www.better-auth.com/docs/plugins/organization - const result = await this.orgApi.setActiveOrganization({ - body: { organizationId: dto.organizationId }, - headers: { - authorization: `Bearer ${dto.userToken}`, - }, - }); - - return result; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found') || error.message?.includes('not a member')) { - throw new NotFoundException('Organization not found or you are not a member'); - } - } - throw error; - } - } - - // ========================================================================= - // Authentication Methods (Sign In / Sign Out / Session) - // ========================================================================= - - /** - * Sign in user with email and password - * - * Authenticates a user and returns their session with JWT token. - * - * @param dto - Sign in credentials - * @returns User data and authentication token - * @throws UnauthorizedException if credentials are invalid - */ - async signIn(dto: SignInDto): Promise { - try { - const result = await this.auth.api.signInEmail({ - body: { - email: dto.email, - password: dto.password, - }, - }); - - // Better Auth returns error objects instead of throwing for some cases - if (result && typeof result === 'object' && 'error' in result) { - const errorResult = result as { - error: { message?: string; code?: string; status?: number }; - }; - const errorMsg = errorResult.error?.message || ''; - const errorCode = errorResult.error?.code || ''; - this.logger.debug(`SignIn error result: ${JSON.stringify(errorResult.error)}`); - - if ( - errorMsg.includes('not verified') || - errorMsg.includes('not confirmed') || - errorCode === 'EMAIL_NOT_VERIFIED' || - errorCode === 'EMAIL_NOT_CONFIRMED' - ) { - throw new ForbiddenException({ - message: 'Email not verified', - code: 'EMAIL_NOT_VERIFIED', - }); - } - } - - // Check if 2FA is required - if ( - result && - typeof result === 'object' && - 'twoFactorRedirect' in result && - (result as any).twoFactorRedirect - ) { - this.logger.debug('SignIn: 2FA required, returning redirect'); - return { - twoFactorRedirect: true, - user: null, - accessToken: '', - refreshToken: '', - expiresIn: 0, - } as any; - } - - if (!hasUser(result)) { - throw new UnauthorizedException('Invalid credentials'); - } - - const { user } = result; - - // Get session token (the cookie token, not the refresh token) - const session = hasSession(result) ? result.session : null; - const sessionToken = session?.token || (hasToken(result) ? result.token : ''); - - // Get the actual refreshToken from the database - const db = getDb(this.databaseUrl); - const { sessions } = await import('../../db/schema'); - const { eq } = await import('drizzle-orm'); - const { nanoid } = await import('nanoid'); - - // Find the session by its token to get the real refreshToken - const [dbSession] = await db - .select() - .from(sessions) - .where(eq(sessions.token, sessionToken)) - .limit(1); - - let actualRefreshToken: string; - - if (dbSession?.refreshToken) { - // Session already has a refreshToken - use it - actualRefreshToken = dbSession.refreshToken; - this.logger.debug('SignIn: Using existing refreshToken from session'); - } else if (dbSession) { - // Session exists but no refreshToken - generate one and update the session - actualRefreshToken = nanoid(64); - const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - - await db - .update(sessions) - .set({ - refreshToken: actualRefreshToken, - refreshTokenExpiresAt, - updatedAt: new Date(), - }) - .where(eq(sessions.id, dbSession.id)); - - this.logger.debug('SignIn: Generated new refreshToken for session'); - } else { - // No session found in DB - this shouldn't happen, but handle it - this.logger.warn('SignIn: Session not found in database, using session token as fallback'); - actualRefreshToken = sessionToken; - } - - // Generate JWT access token using Better Auth's JWT plugin - let accessToken = ''; - try { - const api = this.auth.api as any; - - // Use Better Auth's signJWT with the jwks table - const jwtResult = await api.signJWT({ - body: { - payload: { - sub: user.id, - email: user.email, - role: (user as BetterAuthUser).role || 'user', - sid: session?.id || '', - }, - }, - }); - - accessToken = jwtResult?.token || ''; - - // Fallback to manual JWT if Better Auth fails - if (!accessToken) { - throw new Error('Better Auth signJWT returned empty token'); - } - } catch (jwtError) { - this.logger.warn('Better Auth signJWT failed, using session token as fallback', { - error: jwtError instanceof Error ? jwtError.message : 'Unknown error', - }); - - // Fallback: Use session token (Better Auth manages JWT signing via JWKS) - // NOTE: If signJWT fails repeatedly, check that the auth.jwks table has valid EdDSA keys - accessToken = sessionToken; - } - - return { - user: { - id: user.id, - email: user.email, - name: user.name, - role: (user as BetterAuthUser).role, - }, - accessToken, - refreshToken: actualRefreshToken, - expiresIn: 15 * 60, // 15 minutes in seconds - }; - } catch (error: unknown) { - // Re-throw NestJS HTTP exceptions directly - if (error instanceof ForbiddenException || error instanceof UnauthorizedException) { - throw error; - } - - if (error instanceof Error) { - this.logger.debug(`SignIn caught error: ${error.message}`); - - if ( - error.message?.toLowerCase().includes('not verified') || - error.message?.toLowerCase().includes('not confirmed') || - error.message?.includes('EMAIL_NOT_VERIFIED') - ) { - throw new ForbiddenException({ - message: 'Email not verified', - code: 'EMAIL_NOT_VERIFIED', - }); - } - if ( - error.message?.includes('invalid') || - error.message?.includes('credentials') || - error.message?.includes('not found') - ) { - throw new UnauthorizedException('Invalid email or password'); - } - } - throw error; - } - } - - /** - * Create a session and generate JWT tokens for a user - * Used by passkey authentication and other non-password flows - */ - async createSessionAndTokens( - user: { id: string; email: string; name: string; role?: string }, - meta?: { ipAddress?: string; userAgent?: string; deviceId?: string; deviceName?: string } - ) { - const db = getDb(this.databaseUrl); - const { sessions } = await import('../../db/schema'); - const { nanoid } = await import('nanoid'); - - const sessionId = nanoid(); - const sessionToken = nanoid(64); - const refreshToken = nanoid(64); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - - // Create session in DB - await db.insert(sessions).values({ - id: sessionId, - token: sessionToken, - userId: user.id, - expiresAt, - refreshToken, - refreshTokenExpiresAt, - ipAddress: meta?.ipAddress || null, - userAgent: meta?.userAgent || null, - deviceId: meta?.deviceId || null, - deviceName: meta?.deviceName || null, - lastActivityAt: new Date(), - }); - - // Generate JWT access token - let accessToken = ''; - try { - const api = this.auth.api as any; - const jwtResult = await api.signJWT({ - body: { - payload: { - sub: user.id, - email: user.email, - role: user.role || 'user', - sid: sessionId, - }, - }, - }); - accessToken = jwtResult?.token || ''; - if (!accessToken) throw new Error('signJWT returned empty token'); - } catch (jwtError) { - this.logger.warn('signJWT failed for passkey auth, using session token as fallback'); - accessToken = sessionToken; - } - - return { - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role || 'user', - }, - accessToken, - refreshToken, - expiresIn: 15 * 60, // 15 minutes - }; - } - - /** - * Sign out user - * - * Invalidates the user's session. - * - * @param token - User's authentication token - * @returns Success status - */ - async signOut(token: string): Promise { - try { - // Better Auth uses auth.api.signOut - await (this.auth.api as any).signOut({ - headers: { - authorization: `Bearer ${token}`, - }, - }); - - return { success: true, message: 'Signed out successfully' }; - } catch (error: unknown) { - // Even if signOut fails, we treat it as success for the user - // The session will expire naturally - this.logger.warn('Sign out error (session will expire naturally)', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return { success: true, message: 'Signed out successfully' }; - } - } - - /** - * Get current session - * - * Retrieves the current user's session data. - * - * @param token - User's authentication token - * @returns User and session data - * @throws UnauthorizedException if session is invalid - */ - async getSession(token: string): Promise { - try { - // Better Auth uses auth.api.getSession - const result = await (this.auth.api as any).getSession({ - headers: { - authorization: `Bearer ${token}`, - }, - }); - - if (!hasSession(result)) { - throw new UnauthorizedException('Invalid or expired session'); - } - - return { - user: result.user, - session: result.session, - }; - } catch (error: unknown) { - if (error instanceof Error) { - if ( - error.message?.includes('invalid') || - error.message?.includes('expired') || - error.message?.includes('not found') - ) { - throw new UnauthorizedException('Invalid or expired session'); - } - } - throw error; - } - } - - /** - * List user's organizations - * - * Returns all organizations the user is a member of. - * - * @param token - User's authentication token - * @returns List of organizations - */ - async listOrganizations(token: string): Promise { - try { - const result = await this.orgApi.listOrganizations({ - headers: { - authorization: `Bearer ${token}`, - }, - }); - - // Result is an array of organizations - const organizations = Array.isArray(result) ? result : []; - - return { organizations }; - } catch (error: unknown) { - this.logger.error( - 'Failed to list organizations', - error instanceof Error ? error.stack : undefined - ); - return { organizations: [] }; - } - } - - /** - * Get organization by ID - * - * Returns the full organization details including members. - * - * @param organizationId - Organization ID - * @param token - User's authentication token (optional for public orgs) - * @returns Organization with members - * @throws NotFoundException if organization not found - */ - async getOrganization( - organizationId: string, - token?: string - ): Promise { - try { - const result = await this.orgApi.getFullOrganization({ - query: { organizationId }, - ...(token && { - headers: { - authorization: `Bearer ${token}`, - }, - }), - } as any); - - if (!result || !result.id) { - throw new NotFoundException('Organization not found'); - } - - return { - id: result.id, - name: result.name, - slug: result.slug, - logo: result.logo, - metadata: result.metadata, - createdAt: result.createdAt, - members: hasMembers(result) ? result.members : undefined, - }; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Organization not found'); - } - } - throw error; - } - } - - /** - * Update organization - * - * Updates an organization's name, logo, or metadata. - * Requires owner or admin role. - * - * @param organizationId - Organization ID - * @param data - Fields to update (name, logo, metadata) - * @param token - User's authentication token - * @returns Updated organization - * @throws ForbiddenException if user lacks permission - * @throws NotFoundException if organization not found - */ - async updateOrganization( - organizationId: string, - data: { name?: string; logo?: string; metadata?: Record }, - token: string - ): Promise { - try { - const result = await (this.orgApi as any).updateOrganization({ - body: { - organizationId, - data: { - ...(data.name !== undefined && { name: data.name }), - ...(data.logo !== undefined && { logo: data.logo }), - ...(data.metadata !== undefined && { metadata: data.metadata }), - }, - }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - - return result; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Organization not found'); - } - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('You do not have permission to update this organization'); - } - } - throw error; - } - } - - /** - * Delete organization - * - * Deletes an organization and all its data. - * Requires owner role. - * - * @param organizationId - Organization ID - * @param token - User's authentication token - * @throws ForbiddenException if user is not the owner - * @throws NotFoundException if organization not found - */ - async deleteOrganization(organizationId: string, token: string): Promise { - try { - await (this.orgApi as any).deleteOrganization({ - body: { organizationId }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Organization not found'); - } - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('Only the owner can delete the organization'); - } - } - throw error; - } - } - - /** - * Update member role - * - * Changes a member's role within an organization. - * Requires owner or admin role. - * - * @param organizationId - Organization ID - * @param memberId - Member ID to update - * @param role - New role ('admin' or 'member') - * @param token - User's authentication token - * @returns Updated member - * @throws ForbiddenException if user lacks permission - * @throws NotFoundException if member not found - */ - async updateMemberRole( - organizationId: string, - memberId: string, - role: 'admin' | 'member', - token: string - ): Promise { - try { - const result = await (this.orgApi as any).updateMemberRole({ - body: { - organizationId, - memberId, - role, - }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - - return result?.member || result; - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Member not found'); - } - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('You do not have permission to change member roles'); - } - if (error.message?.includes('owner')) { - throw new ForbiddenException("Cannot change the owner's role"); - } - } - throw error; - } - } - - /** - * List organization invitations - * - * Returns all pending invitations for an organization. - * Requires owner or admin role. - * - * @param organizationId - Organization ID - * @param token - User's authentication token - * @returns List of invitations - */ - async listOrganizationInvitations( - organizationId: string, - token: string - ): Promise { - try { - const result = await (this.orgApi as any).listInvitations({ - query: { organizationId }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - - return result?.invitations || result || []; - } catch (error: unknown) { - this.logger.error( - 'Failed to list organization invitations', - error instanceof Error ? error.stack : undefined - ); - return []; - } - } - - /** - * List user's pending invitations - * - * Returns all pending invitations for the authenticated user. - * - * @param token - User's authentication token - * @returns List of invitations - */ - async listUserInvitations(token: string): Promise { - try { - const result = (await (this.orgApi as any).getInvitation) - ? await (this.orgApi as any).listUserInvitations({ - headers: { - authorization: `Bearer ${token}`, - }, - }) - : []; - - return result?.invitations || result || []; - } catch (error: unknown) { - this.logger.error( - 'Failed to list user invitations', - error instanceof Error ? error.stack : undefined - ); - return []; - } - } - - /** - * Cancel an invitation - * - * Cancels a pending invitation. Used by organization admins/owners. - * - * @param invitationId - Invitation ID - * @param token - User's authentication token - * @throws ForbiddenException if user lacks permission - * @throws NotFoundException if invitation not found - */ - async cancelInvitation(invitationId: string, token: string): Promise { - try { - await (this.orgApi as any).cancelInvitation({ - body: { invitationId }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Invitation not found'); - } - if (error.message?.includes('permission') || error.message?.includes('unauthorized')) { - throw new ForbiddenException('You do not have permission to cancel this invitation'); - } - } - throw error; - } - } - - /** - * Reject an invitation - * - * Rejects a pending invitation. Used by the invited user. - * - * @param invitationId - Invitation ID - * @param token - User's authentication token - * @throws NotFoundException if invitation not found - */ - async rejectInvitation(invitationId: string, token: string): Promise { - try { - await (this.orgApi as any).rejectInvitation({ - body: { invitationId }, - headers: { - authorization: `Bearer ${token}`, - }, - }); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message?.includes('not found')) { - throw new NotFoundException('Invitation not found'); - } - } - throw error; - } - } - - // ========================================================================= - // Token Management Methods - // ========================================================================= - - /** - * Refresh access token - * - * Validates the refresh token and issues new access/refresh tokens. - * Implements refresh token rotation for security. - * - * @param refreshToken - The refresh token to validate - * @returns New access token, refresh token, and user data - * @throws UnauthorizedException if refresh token is invalid or expired - */ - async refreshToken(refreshToken: string): Promise { - const db = getDb(this.databaseUrl); - - try { - // Import sessions schema for refresh token lookup - const { sessions } = await import('../../db/schema'); - const { users } = await import('../../db/schema'); - const { eq, and, isNull } = await import('drizzle-orm'); - const { nanoid } = await import('nanoid'); - const { randomUUID } = await import('crypto'); - - // Find session by refresh token - const [session] = await db - .select() - .from(sessions) - .where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt))) - .limit(1); - - if (!session) { - throw new UnauthorizedException('Invalid refresh token'); - } - - // Check if refresh token is expired - if (!session.refreshTokenExpiresAt || new Date() > session.refreshTokenExpiresAt) { - throw new UnauthorizedException('Refresh token expired'); - } - - // Get user - const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); - - if (!user || user.deletedAt) { - throw new UnauthorizedException('User not found'); - } - - // Revoke old session (refresh token rotation) - await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id)); - - // Generate new session with sliding window expiration - const sessionId = randomUUID(); - const newRefreshToken = nanoid(64); - - // Sliding window: Extend from NOW, preserving rememberMe preference - // This allows active users to stay signed in indefinitely - const wasRememberMe = session.rememberMe ?? false; - const extensionDays = wasRememberMe ? 30 : 7; - const refreshTokenExpiresAt = new Date(Date.now() + extensionDays * 24 * 60 * 60 * 1000); - const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes - - await db.insert(sessions).values({ - id: sessionId, - userId: user.id, - token: sessionId, - refreshToken: newRefreshToken, - refreshTokenExpiresAt, // Extends with each refresh (sliding window) - ipAddress: session.ipAddress, - userAgent: session.userAgent, - deviceId: session.deviceId, - deviceName: session.deviceName, - expiresAt: accessTokenExpiresAt, - rememberMe: wasRememberMe, // Preserve remember me flag - }); - - // Generate new JWT using Better Auth's signJWT (uses JWKS/EdDSA keys) - let accessToken = ''; - try { - const api = this.auth.api as any; - const jwtResult = await api.signJWT({ - body: { - payload: { - sub: user.id, - email: user.email, - role: user.role || 'user', - sid: sessionId, - }, - }, - }); - - accessToken = jwtResult?.token || ''; - - if (!accessToken) { - throw new Error('Better Auth signJWT returned empty token'); - } - } catch (jwtError) { - this.logger.error( - 'Token refresh: JWT generation failed', - jwtError instanceof Error ? jwtError.message : 'Unknown error' - ); - throw new Error('Failed to generate access token'); - } - - return { - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }, - accessToken, - refreshToken: newRefreshToken, - expiresIn: 15 * 60, // 15 minutes in seconds - tokenType: 'Bearer', - }; - } catch (error: unknown) { - if (error instanceof UnauthorizedException) { - throw error; - } - if (error instanceof Error) { - if ( - error.message?.includes('invalid') || - error.message?.includes('expired') || - error.message?.includes('not found') - ) { - throw new UnauthorizedException('Invalid or expired refresh token'); - } - } - throw error; - } - } - - /** - * Validate a JWT token - * - * Verifies the token signature and expiration. - * Returns the decoded payload if valid. - * - * @param token - The JWT token to validate - * @returns Validation result with payload or error - */ - async validateToken(token: string): Promise { - try { - // Use local JWKS cache (reads from DB, no self-referential HTTP requests) - const localJWKS = createCachedLocalJWKSet(this.databaseUrl); - - // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts) - // Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore' - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility - const audience = this.configService.get('jwt.audience') || 'manacore'; - - // Verify using jose library with locally cached JWKS keys - const { payload } = await jwtVerify(token, localJWKS, { - issuer, - audience, - }); - - this.logger.debug('Token validation successful', { userId: payload.sub }); - - return { - valid: true, - payload: payload as unknown as TokenPayload, - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - this.logger.warn('Token validation failed', { error: errorMessage }); - return { - valid: false, - error: errorMessage, - }; - } - } - - // ========================================================================= - // Password Reset Methods - // ========================================================================= - - /** - * Request password reset - * - * Sends a password reset email to the user. - * Uses Better Auth's forgetPassword API. - * - * @param email - User's email address - * @param redirectTo - Optional URL to redirect after reset (used in email link) - * @returns Success status - */ - async requestPasswordReset( - email: string, - redirectTo?: string - ): Promise<{ success: boolean; message: string }> { - try { - // Store the redirect URL so sendResetPassword callback can include it in the email link - if (redirectTo) { - passwordResetRedirectStore.set(email, redirectTo); - } - - // Better Auth's requestPasswordReset endpoint - // See: https://www.better-auth.com/docs/authentication/email-password#password-reset - await (this.auth.api as any).requestPasswordReset({ - body: { - email, - redirectTo, - }, - headers: new Headers(), - }); - - // Always return success to prevent email enumeration - return { - success: true, - message: 'If an account with that email exists, a password reset link has been sent', - }; - } catch (error) { - this.logger.error( - 'Password reset request failed', - error instanceof Error ? error.stack : undefined - ); - // Always return success to prevent email enumeration attacks - return { - success: true, - message: 'If an account with that email exists, a password reset link has been sent', - }; - } - } - - /** - * Reset password with token - * - * Resets the user's password using the token from the reset email. - * Uses Better Auth's resetPassword API. - * - * @param token - Reset token from email link - * @param newPassword - New password to set - * @returns Success status - * @throws UnauthorizedException if token is invalid or expired - */ - async resetPassword( - token: string, - newPassword: string - ): Promise<{ success: boolean; message: string }> { - try { - // Better Auth's resetPassword method - // See: https://www.better-auth.com/docs/authentication/email-password#password-reset - await (this.auth.api as any).resetPassword({ - body: { - token, - newPassword, - }, - }); - - return { - success: true, - message: 'Password has been reset successfully', - }; - } catch (error: unknown) { - if (error instanceof Error) { - if ( - error.message?.includes('invalid') || - error.message?.includes('expired') || - error.message?.includes('not found') - ) { - throw new UnauthorizedException('Invalid or expired reset token'); - } - } - throw error; - } - } - - /** - * Verify email with token - * - * Verifies the user's email using the token from the verification email. - * Uses Better Auth's verifyEmail API. - * - * @param token - Verification token from email link - * @returns Success status and user email - */ - async verifyEmail(token: string): Promise<{ success: boolean; email?: string; error?: string }> { - try { - // Better Auth's verifyEmail method - // See: https://www.better-auth.com/docs/authentication/email-verification - const api = this.auth.api as any; - - const result = await api.verifyEmail({ - query: { token }, - }); - - // Extract email from result if available - const email = result?.user?.email || result?.email; - this.logger.debug('Email verification successful', { email }); - - return { - success: true, - email, - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - this.logger.warn('Email verification failed', { error: errorMessage }); - - if (errorMessage.includes('invalid') || errorMessage.includes('expired')) { - return { - success: false, - error: 'invalid_or_expired_token', - }; - } - - return { - success: false, - error: 'verification_failed', - }; - } - } - - /** - * Resend verification email - * - * Sends a new verification email to the user. - * Uses Better Auth's sendVerificationEmail API. - * - * @param email - User's email address - * @param sourceAppUrl - Optional URL to redirect after verification - * @returns Success status (always returns success to prevent email enumeration) - */ - async resendVerificationEmail( - email: string, - sourceAppUrl?: string - ): Promise<{ success: boolean; message: string }> { - try { - // Store source app URL for email verification redirect - if (sourceAppUrl) { - sourceAppStore.set(email, sourceAppUrl); - } - - // Better Auth's sendVerificationEmail method - // See: https://www.better-auth.com/docs/authentication/email-verification - const api = this.auth.api as any; - - await api.sendVerificationEmail({ - body: { email }, - }); - - // Always return success to prevent email enumeration - return { - success: true, - message: 'If an account with that email exists, a verification email has been sent', - }; - } catch (error) { - this.logger.error( - 'Resend verification email failed', - error instanceof Error ? error.stack : undefined - ); - // Always return success to prevent email enumeration attacks - return { - success: true, - message: 'If an account with that email exists, a verification email has been sent', - }; - } - } - - /** - * Get JWKS (JSON Web Key Set) - * - * Returns public keys for JWT verification. - * Proxies to Better Auth's internal JWKS. - * - * @returns JWKS with public keys - */ - async getJwks(): Promise<{ keys: unknown[] }> { - try { - // Better Auth exposes JWKS via auth.api - const api = this.auth.api as any; - - // Try to get JWKS from Better Auth - if (api.getJwks) { - const result = await api.getJwks(); - return result; - } - - // Fallback: read from jwks table directly - const db = getDb(this.databaseUrl); - const { jwks } = await import('../../db/schema/auth.schema'); - const keys = await db.select().from(jwks); - - // Convert to JWKS format (EdDSA public keys) - return { - keys: keys.map((key) => { - try { - return JSON.parse(key.publicKey); - } catch { - return { kid: key.id, publicKey: key.publicKey }; - } - }), - }; - } catch (error) { - this.logger.error('Failed to get JWKS', error instanceof Error ? error.stack : undefined); - return { keys: [] }; - } - } - - // ========================================================================= - // Source App URL Methods - // ========================================================================= - - /** - * Get and remove source app URL for an email - * - * Used after email verification to redirect user to the correct app. - * The entry is deleted after retrieval to prevent re-use. - * - * @param email - User's email address - * @returns Source app URL or null if not found - */ - getSourceAppUrl(email: string): string | null { - return sourceAppStore.getAndDelete(email); - } - - // ========================================================================= - // Profile Management Methods - // ========================================================================= - - /** - * Update user profile - * - * Updates the user's name and/or image. - * - * @param userId - User ID - * @param updates - Fields to update (name, image) - * @returns Updated user data - */ - async updateProfile( - userId: string, - updates: { name?: string; image?: string } - ): Promise<{ - success: boolean; - user: { id: string; name: string; email: string; image?: string }; - }> { - const db = getDb(this.databaseUrl); - const { users } = await import('../../db/schema/auth.schema'); - const { eq } = await import('drizzle-orm'); - - // Get current user - const [currentUser] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - - if (!currentUser || currentUser.deletedAt) { - throw new NotFoundException('User not found'); - } - - // Build update object - const updateData: Partial<{ name: string; image: string; updatedAt: Date }> = { - updatedAt: new Date(), - }; - - if (updates.name !== undefined) { - updateData.name = updates.name; - } - - if (updates.image !== undefined) { - updateData.image = updates.image; - } - - // Update user - const [updatedUser] = await db - .update(users) - .set(updateData) - .where(eq(users.id, userId)) - .returning(); - - this.logger.log('Profile updated', { userId }); - - return { - success: true, - user: { - id: updatedUser.id, - name: updatedUser.name, - email: updatedUser.email, - image: updatedUser.image || undefined, - }, - }; - } - - /** - * Change user password - * - * Verifies the current password and updates to the new one. - * Requires the user to be authenticated. - * - * @param userId - User ID - * @param currentPassword - Current password for verification - * @param newPassword - New password to set - * @returns Success status - * @throws UnauthorizedException if current password is incorrect - */ - async changePassword( - userId: string, - currentPassword: string, - newPassword: string - ): Promise<{ success: boolean; message: string }> { - const db = getDb(this.databaseUrl); - const { accounts } = await import('../../db/schema/auth.schema'); - const { eq, and } = await import('drizzle-orm'); - const bcrypt = await import('bcryptjs'); - - // Get credential account (where password is stored) - const [account] = await db - .select() - .from(accounts) - .where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential'))) - .limit(1); - - if (!account || !account.password) { - throw new NotFoundException('No password credential found for this account'); - } - - // Verify current password - const isValid = await bcrypt.compare(currentPassword, account.password); - - if (!isValid) { - throw new UnauthorizedException('Current password is incorrect'); - } - - // Hash new password - const hashedPassword = await bcrypt.hash(newPassword, 10); - - // Update password - await db - .update(accounts) - .set({ - password: hashedPassword, - updatedAt: new Date(), - }) - .where(eq(accounts.id, account.id)); - - this.logger.log('Password changed', { userId }); - - return { - success: true, - message: 'Password changed successfully', - }; - } - - /** - * Delete user account - * - * Soft-deletes the user account after password verification. - * Sets deletedAt timestamp instead of hard delete for data retention. - * - * @param userId - User ID - * @param password - Password for verification - * @param reason - Optional reason for deletion - * @returns Success status - * @throws UnauthorizedException if password is incorrect - */ - async deleteAccount( - userId: string, - password: string, - reason?: string - ): Promise<{ success: boolean; message: string }> { - const db = getDb(this.databaseUrl); - const { accounts, users, sessions } = await import('../../db/schema/auth.schema'); - const { eq, and } = await import('drizzle-orm'); - const bcrypt = await import('bcryptjs'); - - // Get credential account - const [account] = await db - .select() - .from(accounts) - .where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential'))) - .limit(1); - - if (!account || !account.password) { - throw new NotFoundException('No password credential found for this account'); - } - - // Verify password - const isValid = await bcrypt.compare(password, account.password); - - if (!isValid) { - throw new UnauthorizedException('Password is incorrect'); - } - - const now = new Date(); - - // Soft delete user - await db.update(users).set({ deletedAt: now, updatedAt: now }).where(eq(users.id, userId)); - - // Revoke all sessions - await db.update(sessions).set({ revokedAt: now }).where(eq(sessions.userId, userId)); - - this.logger.log('Account deleted', { userId, reason }); - - return { - success: true, - message: 'Account has been deleted', - }; - } - - /** - * Get user profile - * - * Returns the full user profile data. - * - * @param userId - User ID - * @returns User profile data - */ - async getProfile(userId: string): Promise<{ - id: string; - name: string; - email: string; - emailVerified: boolean; - image?: string; - role: string; - createdAt: Date; - }> { - const db = getDb(this.databaseUrl); - const { users } = await import('../../db/schema/auth.schema'); - const { eq } = await import('drizzle-orm'); - - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - - if (!user || user.deletedAt) { - throw new NotFoundException('User not found'); - } - - return { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - image: user.image || undefined, - role: user.role, - createdAt: user.createdAt, - }; - } - - // ========================================================================= - // Private Helper Methods - // ========================================================================= - - /** - * Create personal credit balance for user - * - * Initializes a user's credit balance with balance: 0 - * Users must purchase credits or receive them as gifts. - * - * @param userId - User ID - * @private - */ - /** - * Initialize credit balance via mana-credits service. - * Non-critical — lazy init on first access if this fails. - */ - private async createPersonalCreditBalance(userId: string) { - try { - const creditsUrl = process.env.MANA_CREDITS_URL || 'http://localhost:3061'; - const serviceKey = process.env.MANA_CORE_SERVICE_KEY || ''; - await fetch(`${creditsUrl}/api/v1/internal/credits/init`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Service-Key': serviceKey }, - body: JSON.stringify({ userId }), - }); - } catch (error) { - this.logger.warn('Failed to init credit balance via mana-credits (non-critical)', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - /** - * Initialize guild pool via mana-credits service. - * Non-critical — lazy init on first access if this fails. - */ - private async initializeGuildPool(organizationId: string) { - try { - const creditsUrl = process.env.MANA_CREDITS_URL || 'http://localhost:3061'; - const serviceKey = process.env.MANA_CORE_SERVICE_KEY || ''; - await fetch(`${creditsUrl}/api/v1/internal/guild-pool/init`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Service-Key': serviceKey }, - body: JSON.stringify({ organizationId }), - }); - } catch (error) { - this.logger.warn('Failed to init guild pool via mana-credits (non-critical)', { - organizationId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - /** - * Helper function to create URL-safe slugs - * - * Converts organization name to lowercase, URL-safe slug. - * Example: "Acme Corporation" -> "acme-corporation" - * - * @param text - Text to slugify - * @returns URL-safe slug - * @private - */ - private slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, '') // Remove special characters - .replace(/\s+/g, '-') // Replace spaces with hyphens - .replace(/--+/g, '-') // Replace multiple hyphens with single - .trim(); - } - - // ========================================================================= - // OIDC Provider Methods - // ========================================================================= - - /** - * Handle OIDC request by forwarding to Better Auth's handler - * - * This method converts an Express request to a Fetch Request, - * passes it to Better Auth's handler, and returns the response. - * - * Better Auth's OIDC Provider uses routes under /api/auth/oauth2/ - * so we need to map incoming routes accordingly: - * - /.well-known/openid-configuration → /api/auth/.well-known/openid-configuration - * - /api/oidc/authorize → /api/auth/oauth2/authorize - * - /api/oidc/token → /api/auth/oauth2/token - * - /api/oidc/userinfo → /api/auth/oauth2/userinfo - * - /api/oidc/jwks → /api/auth/jwks (JWKS is at basePath, not oauth2) - * - * @param req - Express request - * @returns Response data from Better Auth - */ - async handleOidcRequest(req: import('express').Request): Promise<{ - status: number; - headers: Record; - body: unknown; - }> { - try { - // Map incoming paths to Better Auth's expected paths - let mappedPath = req.originalUrl; - - // Map .well-known to Better Auth's basePath - if (mappedPath.startsWith('/.well-known/')) { - mappedPath = `/api/auth${mappedPath}`; - } - // Map /api/oidc/jwks to /api/auth/jwks (JWKS is not under oauth2) - else if (mappedPath.startsWith('/api/oidc/jwks')) { - mappedPath = mappedPath.replace('/api/oidc/jwks', '/api/auth/jwks'); - } - // Map /api/oidc/* to /api/auth/oauth2/* - else if (mappedPath.startsWith('/api/oidc/')) { - mappedPath = mappedPath.replace('/api/oidc/', '/api/auth/oauth2/'); - } - - // Convert Express request to Fetch Request - const url = new URL( - mappedPath, - this.configService.get('BASE_URL') || - `http://localhost:${this.configService.get('PORT') || 3001}` - ); - - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value) { - headers.set(key, Array.isArray(value) ? value[0] : value); - } - } - - // Prepare body based on content type - let requestBody: string | undefined; - if (req.method !== 'GET' && req.method !== 'HEAD' && req.body) { - const contentType = req.headers['content-type'] || ''; - if (contentType.includes('application/x-www-form-urlencoded')) { - // Convert object to URL-encoded form data - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(req.body)) { - if (value !== undefined && value !== null) { - params.append(key, String(value)); - } - } - requestBody = params.toString(); - } else { - // Default to JSON - requestBody = JSON.stringify(req.body); - // Ensure content-type is set for JSON - if (!headers.has('content-type')) { - headers.set('content-type', 'application/json'); - } - } - } - - // Create Fetch Request - const fetchRequest = new Request(url.toString(), { - method: req.method, - headers, - body: requestBody, - }); - - // Call Better Auth's handler - const response = await this.auth.handler(fetchRequest); - - // Convert Response to our format - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - - // Get body - handle empty responses gracefully - let body: unknown; - const contentType = response.headers.get('content-type'); - const textBody = await response.text(); - - if (contentType?.includes('application/json') && textBody.length > 0) { - try { - body = JSON.parse(textBody); - } catch { - body = textBody; - } - } else { - body = textBody; - } - - return { - status: response.status, - headers: responseHeaders, - body, - }; - } catch (error) { - this.logger.error( - 'OIDC request handling failed', - error instanceof Error ? error.stack : undefined - ); - throw error; - } - } - - // ========================================================================= - // Matrix Bot SSO Methods - // ========================================================================= - - /** - * Generate a JWT token for a specific user (used by Matrix bots) - * - * This method generates a fresh JWT token for an existing user, - * without requiring password authentication. It's used by the - * Matrix-SSO-Link system to auto-authenticate bot users. - * - * @param userId - Mana Core Auth user ID - * @returns JWT access token or null if user not found - */ - async generateTokenForUser(userId: string): Promise { - try { - const db = getDb(this.databaseUrl); - const { users } = await import('../../db/schema/auth.schema'); - const { eq } = await import('drizzle-orm'); - - // Get user from database - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - - if (!user || user.deletedAt) { - this.logger.warn('generateTokenForUser: User not found', { userId }); - return null; - } - - // Generate JWT using Better Auth's signJWT - const api = this.auth.api as any; - - const jwtResult = await api.signJWT({ - body: { - payload: { - sub: user.id, - email: user.email, - role: user.role || 'user', - sid: `bot-session-${Date.now()}`, // Pseudo session ID for bots - }, - }, - }); - - const token = jwtResult?.token; - - if (!token) { - this.logger.error('generateTokenForUser: signJWT returned empty token'); - return null; - } - - this.logger.debug('Generated token for user via Matrix-SSO-Link', { userId }); - return token; - } catch (error) { - this.logger.error( - 'generateTokenForUser failed', - error instanceof Error ? error.stack : undefined - ); - return null; - } - } - - // ========================================================================= - // SSO Methods - // ========================================================================= - - /** - * Exchange session cookie for JWT tokens (SSO) - * - * This enables cross-domain Single Sign-On. When a user is logged in - * on one app (e.g., calendar.mana.how), they have a session cookie on - * .mana.how domain. This method allows other apps to exchange that - * cookie for JWT tokens they can use for API calls. - * - * @param req - Express request with cookies - * @param res - Express response for setting headers - * @returns JWT tokens or throws UnauthorizedException - */ - async sessionToToken( - req: import('express').Request, - res: import('express').Response - ): Promise { - try { - // Get session cookie name (Better Auth uses this format with our prefix) - const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'; - const sessionCookieName = `__Secure-${cookiePrefix}.session_token`; - const fallbackCookieName = `${cookiePrefix}.session_token`; - - // Try to get session token from cookies - const sessionToken = req.cookies?.[sessionCookieName] || req.cookies?.[fallbackCookieName]; - - if (!sessionToken) { - this.logger.debug('SSO: No session cookie found', { - cookies: Object.keys(req.cookies || {}), - }); - throw new UnauthorizedException('No session cookie found'); - } - - this.logger.debug('SSO: Found session cookie, validating...'); - - // Use Better Auth's getSession to validate the cookie - // We need to create a Request object that Better Auth can process - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const url = new URL('/api/auth/get-session', baseUrl); - - const headers = new Headers({ - Cookie: `${sessionCookieName}=${sessionToken}`, - }); - - const fetchRequest = new Request(url.toString(), { - method: 'GET', - headers, - }); - - const response = await this.auth.handler(fetchRequest); - - if (!response.ok) { - this.logger.debug('SSO: Session validation failed', { status: response.status }); - throw new UnauthorizedException('Invalid or expired session'); - } - - const sessionData = await response.json(); - - if (!sessionData?.user || !sessionData?.session) { - this.logger.debug('SSO: Invalid session response', { sessionData }); - throw new UnauthorizedException('Invalid session data'); - } - - const { user, session } = sessionData; - - this.logger.debug('SSO: Session validated, generating JWT tokens', { - userId: user.id, - email: user.email, - }); - - // Get the actual session from database to retrieve the real refreshToken - const db = getDb(this.databaseUrl); - const { sessions } = await import('../../db/schema'); - const { eq } = await import('drizzle-orm'); - const { nanoid } = await import('nanoid'); - - // Find the session by its token (session.token is the cookie token) - const [dbSession] = await db - .select() - .from(sessions) - .where(eq(sessions.token, session.token || sessionToken)) - .limit(1); - - let actualRefreshToken: string; - - if (dbSession?.refreshToken) { - // Session already has a refreshToken - use it - actualRefreshToken = dbSession.refreshToken; - this.logger.debug('SSO: Using existing refreshToken from session'); - } else if (dbSession) { - // Session exists but no refreshToken - generate one and update the session - actualRefreshToken = nanoid(64); - const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - - await db - .update(sessions) - .set({ - refreshToken: actualRefreshToken, - refreshTokenExpiresAt, - updatedAt: new Date(), - }) - .where(eq(sessions.id, dbSession.id)); - - this.logger.debug('SSO: Generated new refreshToken for existing session'); - } else { - // No session found in DB - this shouldn't happen, but handle it - this.logger.warn('SSO: Session not found in database, using session token as fallback'); - actualRefreshToken = session.token || sessionToken; - } - - // Generate JWT access token using Better Auth's JWT plugin - let accessToken = ''; - try { - const api = this.auth.api as any; - - const jwtResult = await api.signJWT({ - body: { - payload: { - sub: user.id, - email: user.email, - role: user.role || 'user', - sid: session.id || '', - }, - }, - }); - - accessToken = jwtResult?.token || ''; - - if (!accessToken) { - throw new Error('Better Auth signJWT returned empty token'); - } - } catch (jwtError) { - this.logger.warn('SSO: JWT generation failed, using session token', { - error: jwtError instanceof Error ? jwtError.message : 'Unknown error', - }); - // Use session token as fallback - accessToken = session.token || sessionToken; - } - - this.logger.info('SSO: Successfully exchanged session cookie for JWT tokens', { - userId: user.id, - }); - - return { - user: { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }, - accessToken, - refreshToken: actualRefreshToken, - expiresIn: 15 * 60, // 15 minutes in seconds - }; - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - - this.logger.error( - 'SSO: Token exchange failed', - error instanceof Error ? error.stack : undefined - ); - throw new UnauthorizedException('Failed to exchange session for tokens'); - } - } - - /** - * Get security events for a user (audit log) - */ - async getSecurityEvents(userId: string, limit = 50) { - const db = getDb(this.databaseUrl); - const { securityEvents } = await import('../../db/schema'); - const { eq, desc } = await import('drizzle-orm'); - - const events = await db - .select({ - id: securityEvents.id, - eventType: securityEvents.eventType, - ipAddress: securityEvents.ipAddress, - userAgent: securityEvents.userAgent, - metadata: securityEvents.metadata, - createdAt: securityEvents.createdAt, - }) - .from(securityEvents) - .where(eq(securityEvents.userId, userId)) - .orderBy(desc(securityEvents.createdAt)) - .limit(limit); - - return events; - } - - /** - * List active sessions for a user - */ - async listSessions(userId: string) { - const db = getDb(this.databaseUrl); - const { sessions } = await import('../../db/schema'); - const { eq, and, isNull, gt } = await import('drizzle-orm'); - - const activeSessions = await db - .select({ - id: sessions.id, - ipAddress: sessions.ipAddress, - userAgent: sessions.userAgent, - deviceId: sessions.deviceId, - deviceName: sessions.deviceName, - lastActivityAt: sessions.lastActivityAt, - createdAt: sessions.createdAt, - expiresAt: sessions.expiresAt, - }) - .from(sessions) - .where( - and( - eq(sessions.userId, userId), - isNull(sessions.revokedAt), - gt(sessions.expiresAt, new Date()) - ) - ) - .orderBy(sessions.lastActivityAt); - - return activeSessions; - } - - /** - * Revoke a specific session - */ - async revokeSession(userId: string, sessionId: string) { - const db = getDb(this.databaseUrl); - const { sessions } = await import('../../db/schema'); - const { eq, and } = await import('drizzle-orm'); - - const result = await db - .update(sessions) - .set({ revokedAt: new Date() }) - .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId))) - .returning({ id: sessions.id }); - - if (result.length === 0) { - throw new NotFoundException('Session not found'); - } - } -} diff --git a/services/mana-core-auth/src/auth/services/matrix-session.service.ts b/services/mana-core-auth/src/auth/services/matrix-session.service.ts deleted file mode 100644 index 77cf25f27..000000000 --- a/services/mana-core-auth/src/auth/services/matrix-session.service.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { Injectable, Logger, UnauthorizedException, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import { getDb } from '../../db/connection'; -import { matrixUserLinks, users } from '../../db/schema/auth.schema'; -import { BetterAuthService } from './better-auth.service'; - -/** - * Matrix Session Service - * - * Manages the link between Matrix user IDs and Mana Core Auth accounts. - * Enables automatic bot authentication for users who have linked their accounts. - * - * Flow: - * 1. User logs into a Matrix bot via !login email password - * 2. Bot calls POST /api/v1/auth/matrix-user-links to store the link - * 3. Later, bot can call GET /api/v1/auth/matrix-session/:matrixUserId - * 4. If a link exists, a fresh JWT token is returned - */ -@Injectable() -export class MatrixSessionService { - private readonly logger = new Logger(MatrixSessionService.name); - private readonly db; - private readonly serviceKey: string; - - constructor( - private readonly configService: ConfigService, - private readonly betterAuthService: BetterAuthService - ) { - const databaseUrl = this.configService.get('DATABASE_URL'); - if (!databaseUrl) { - throw new Error('DATABASE_URL is required'); - } - this.db = getDb(databaseUrl); - this.serviceKey = this.configService.get('MANA_CORE_SERVICE_KEY', ''); - } - - /** - * Validate service key from X-Service-Key header - */ - validateServiceKey(providedKey: string): boolean { - if (!this.serviceKey) { - this.logger.warn('MANA_CORE_SERVICE_KEY not configured - service key validation disabled'); - return false; - } - return providedKey === this.serviceKey; - } - - /** - * Create or update a link between a Matrix user ID and a Mana user - * - * @param matrixUserId - Matrix user ID (e.g., @user:matrix.mana.how) - * @param manaUserId - Mana Core Auth user ID - * @param email - User's email (optional, for convenience) - */ - async linkMatrixUser(matrixUserId: string, manaUserId: string, email?: string): Promise { - // Check if link already exists - const existing = await this.db - .select() - .from(matrixUserLinks) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)) - .limit(1); - - if (existing.length > 0) { - // Update existing link - await this.db - .update(matrixUserLinks) - .set({ - userId: manaUserId, - email, - lastUsedAt: new Date(), - }) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)); - - this.logger.log(`Updated Matrix link: ${matrixUserId} -> ${manaUserId}`); - } else { - // Create new link - await this.db.insert(matrixUserLinks).values({ - id: nanoid(), - matrixUserId, - userId: manaUserId, - email, - linkedAt: new Date(), - }); - - this.logger.log(`Created Matrix link: ${matrixUserId} -> ${manaUserId}`); - } - } - - /** - * Remove a link for a Matrix user ID - */ - async unlinkMatrixUser(matrixUserId: string): Promise { - const result = await this.db - .delete(matrixUserLinks) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)) - .returning(); - - if (result.length > 0) { - this.logger.log(`Removed Matrix link: ${matrixUserId}`); - return true; - } - return false; - } - - /** - * Get a fresh JWT token for a linked Matrix user - * - * @param matrixUserId - Matrix user ID - * @returns JWT token or null if no link exists - */ - async getSessionForMatrixUser( - matrixUserId: string - ): Promise<{ token: string; email: string } | null> { - // Find the link - const links = await this.db - .select({ - userId: matrixUserLinks.userId, - email: matrixUserLinks.email, - }) - .from(matrixUserLinks) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)) - .limit(1); - - if (links.length === 0) { - return null; - } - - const link = links[0]; - - // Update last used timestamp - await this.db - .update(matrixUserLinks) - .set({ lastUsedAt: new Date() }) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)); - - // Get user details if email not stored - let email = link.email; - if (!email) { - const userRecords = await this.db - .select({ email: users.email }) - .from(users) - .where(eq(users.id, link.userId)) - .limit(1); - - if (userRecords.length > 0) { - email = userRecords[0].email; - } - } - - // Generate a fresh JWT token for this user - const token = await this.betterAuthService.generateTokenForUser(link.userId); - - if (!token) { - this.logger.error(`Failed to generate token for user ${link.userId}`); - return null; - } - - this.logger.debug(`Generated token for Matrix user ${matrixUserId}`); - return { token, email: email || '' }; - } - - /** - * Get all Matrix links for a Mana user - */ - async getLinksForUser(manaUserId: string): Promise<{ matrixUserId: string; linkedAt: Date }[]> { - const links = await this.db - .select({ - matrixUserId: matrixUserLinks.matrixUserId, - linkedAt: matrixUserLinks.linkedAt, - }) - .from(matrixUserLinks) - .where(eq(matrixUserLinks.userId, manaUserId)); - - return links; - } - - /** - * Check if a Matrix user is linked - */ - async isLinked(matrixUserId: string): Promise { - const links = await this.db - .select({ id: matrixUserLinks.id }) - .from(matrixUserLinks) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)) - .limit(1); - - return links.length > 0; - } - - /** - * Auto-link Matrix user during OIDC login - * - * Called when a user logs into Matrix via OIDC (Sign in with Mana Core). - * Creates the Matrix user link automatically so bots can recognize them. - * - * @param manaUserId - Mana Core Auth user ID - * @param email - User's email address - * @param matrixDomain - Matrix homeserver domain (default: matrix.mana.how) - */ - async autoLinkOnOidcLogin( - manaUserId: string, - email: string, - matrixDomain = 'matrix.mana.how' - ): Promise { - try { - // Calculate Matrix user ID from email using Synapse's template: - // localpart_template: "{{ user.email.split('@')[0] }}" - const localpart = email.split('@')[0].toLowerCase(); - const matrixUserId = `@${localpart}:${matrixDomain}`; - - // Check if link already exists - const existing = await this.db - .select() - .from(matrixUserLinks) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)) - .limit(1); - - if (existing.length > 0) { - // Update existing link (in case user ID changed) - if (existing[0].userId !== manaUserId) { - await this.db - .update(matrixUserLinks) - .set({ - userId: manaUserId, - email, - lastUsedAt: new Date(), - }) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)); - this.logger.log(`Updated Matrix auto-link: ${matrixUserId} -> ${manaUserId}`); - } else { - // Just update lastUsedAt - await this.db - .update(matrixUserLinks) - .set({ lastUsedAt: new Date() }) - .where(eq(matrixUserLinks.matrixUserId, matrixUserId)); - } - return; - } - - // Create new link - await this.db.insert(matrixUserLinks).values({ - id: nanoid(), - matrixUserId, - userId: manaUserId, - email, - linkedAt: new Date(), - }); - - this.logger.log(`Created Matrix auto-link on OIDC login: ${matrixUserId} -> ${manaUserId}`); - } catch (error) { - // Log but don't throw - this is a best-effort operation - this.logger.error( - 'Failed to auto-link Matrix user on OIDC login', - error instanceof Error ? error.stack : undefined - ); - } - } -} diff --git a/services/mana-core-auth/src/auth/services/passkey.service.spec.ts b/services/mana-core-auth/src/auth/services/passkey.service.spec.ts deleted file mode 100644 index c96a99dcb..000000000 --- a/services/mana-core-auth/src/auth/services/passkey.service.spec.ts +++ /dev/null @@ -1,612 +0,0 @@ -/** - * PasskeyService Unit Tests - * - * Tests WebAuthn passkey registration, authentication, and management. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; -import { - generateRegistrationOptions, - verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse, -} from '@simplewebauthn/server'; -import { PasskeyService } from './passkey.service'; -import { getDb } from '../../db/connection'; -import { nanoid } from 'nanoid'; -import { LoggerService } from '../../common/logger'; - -jest.mock('@simplewebauthn/server', () => ({ - generateRegistrationOptions: jest.fn(), - verifyRegistrationResponse: jest.fn(), - generateAuthenticationOptions: jest.fn(), - verifyAuthenticationResponse: jest.fn(), -})); - -jest.mock('../../db/connection', () => ({ - getDb: jest.fn(), -})); - -jest.mock('nanoid', () => ({ - nanoid: jest.fn(() => 'mock-nanoid-id'), -})); - -const createMockDb = () => { - let results: any[] = []; - let resultIndex = 0; - - const db: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - then: jest.fn((resolve) => resolve(results[resultIndex++] || [])), - setResults: (...r: any[]) => { - results = r; - resultIndex = 0; - }, - }; - return db; -}; - -describe('PasskeyService', () => { - let service: PasskeyService; - let mockDb: ReturnType; - - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: string) => { - const config: Record = { - 'database.url': 'postgresql://test:test@localhost:5432/test', - WEBAUTHN_RP_ID: 'localhost', - WEBAUTHN_ORIGINS: 'http://localhost:5173', - }; - return config[key] || defaultValue || ''; - }), - }; - - const mockLoggerService = { - setContext: jest.fn().mockReturnThis(), - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }; - - beforeEach(async () => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); - - mockDb = createMockDb(); - (getDb as jest.Mock).mockReturnValue(mockDb); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PasskeyService, - { provide: ConfigService, useValue: mockConfigService }, - { provide: LoggerService, useValue: mockLoggerService }, - ], - }).compile(); - - service = module.get(PasskeyService); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.useRealTimers(); - }); - - // ============================================================================ - // generateRegistrationOptions - // ============================================================================ - - describe('generateRegistrationOptions', () => { - it('should return options and challengeId for a valid user', async () => { - const mockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - }; - - const mockOptions = { - challenge: 'mock-challenge-string', - rp: { name: 'ManaCore', id: 'localhost' }, - user: { id: 'user-123', name: 'test@example.com', displayName: 'Test User' }, - }; - - // First query: get user; Second query: get existing passkeys - mockDb.setResults([mockUser], []); - (generateRegistrationOptions as jest.Mock).mockResolvedValue(mockOptions); - - const result = await service.generateRegistrationOptions('user-123'); - - expect(result.options).toEqual(mockOptions); - expect(result.challengeId).toBe('mock-nanoid-id'); - expect(generateRegistrationOptions).toHaveBeenCalledWith( - expect.objectContaining({ - rpName: 'ManaCore', - rpID: 'localhost', - userName: 'test@example.com', - userDisplayName: 'Test User', - attestationType: 'none', - excludeCredentials: [], - }) - ); - }); - - it('should exclude existing passkeys', async () => { - const mockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - }; - - const existingPasskeys = [ - { credentialId: 'cred-1', transports: ['usb', 'ble'] }, - { credentialId: 'cred-2', transports: ['internal'] }, - ]; - - mockDb.setResults([mockUser], existingPasskeys); - (generateRegistrationOptions as jest.Mock).mockResolvedValue({ - challenge: 'mock-challenge', - }); - - await service.generateRegistrationOptions('user-123'); - - expect(generateRegistrationOptions).toHaveBeenCalledWith( - expect.objectContaining({ - excludeCredentials: [ - { id: 'cred-1', transports: ['usb', 'ble'] }, - { id: 'cred-2', transports: ['internal'] }, - ], - }) - ); - }); - - it('should throw NotFoundException for non-existent user', async () => { - mockDb.setResults([]); - - await expect(service.generateRegistrationOptions('nonexistent')).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // verifyRegistration - // ============================================================================ - - describe('verifyRegistration', () => { - const mockCredential = { - id: 'cred-id-123', - rawId: 'raw-id', - response: { attestationObject: 'obj', clientDataJSON: 'json' }, - type: 'public-key', - }; - - it('should store passkey on successful verification', async () => { - // First, generate a registration to store a challenge - const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; - mockDb.setResults([mockUser], []); - (generateRegistrationOptions as jest.Mock).mockResolvedValue({ - challenge: 'test-challenge', - }); - const { challengeId } = await service.generateRegistrationOptions('user-123'); - - // Reset DB mock for verification calls - const publicKeyBytes = new Uint8Array([1, 2, 3, 4]); - const mockVerification = { - verified: true, - registrationInfo: { - credential: { - id: 'new-cred-id', - publicKey: publicKeyBytes, - counter: 0, - transports: ['internal'], - }, - credentialDeviceType: 'multiPlatform', - credentialBackedUp: true, - }, - }; - - (verifyRegistrationResponse as jest.Mock).mockResolvedValue(mockVerification); - - const newPasskey = { - id: 'mock-nanoid-id', - credentialId: 'new-cred-id', - deviceType: 'multiPlatform', - friendlyName: 'My Key', - createdAt: new Date(), - }; - - // duplicate check (empty), insert returning - mockDb.setResults([], [newPasskey]); - - const result = await service.verifyRegistration(challengeId, mockCredential as any, 'My Key'); - - expect(result.id).toBe('mock-nanoid-id'); - expect(result.credentialId).toBe('new-cred-id'); - expect(result.deviceType).toBe('multiPlatform'); - expect(result.friendlyName).toBe('My Key'); - expect(verifyRegistrationResponse).toHaveBeenCalledWith( - expect.objectContaining({ - expectedChallenge: 'test-challenge', - expectedOrigin: ['http://localhost:5173'], - expectedRPID: 'localhost', - }) - ); - }); - - it('should throw BadRequestException for expired/invalid challenge', async () => { - await expect( - service.verifyRegistration('nonexistent-challenge', mockCredential as any) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException when verification fails', async () => { - // Store a challenge first - const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; - mockDb.setResults([mockUser], []); - (generateRegistrationOptions as jest.Mock).mockResolvedValue({ - challenge: 'test-challenge', - }); - const { challengeId } = await service.generateRegistrationOptions('user-123'); - - (verifyRegistrationResponse as jest.Mock).mockResolvedValue({ - verified: false, - registrationInfo: null, - }); - - await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow( - BadRequestException - ); - }); - - it('should throw ConflictException for duplicate credentialId', async () => { - // Store a challenge first - const mockUser = { id: 'user-123', email: 'test@example.com', name: 'Test' }; - mockDb.setResults([mockUser], []); - (generateRegistrationOptions as jest.Mock).mockResolvedValue({ - challenge: 'test-challenge', - }); - const { challengeId } = await service.generateRegistrationOptions('user-123'); - - const publicKeyBytes = new Uint8Array([1, 2, 3, 4]); - (verifyRegistrationResponse as jest.Mock).mockResolvedValue({ - verified: true, - registrationInfo: { - credential: { - id: 'existing-cred', - publicKey: publicKeyBytes, - counter: 0, - transports: [], - }, - credentialDeviceType: 'singleDevice', - credentialBackedUp: false, - }, - }); - - // Duplicate check returns existing passkey - mockDb.setResults([{ id: 'existing-pk', credentialId: 'existing-cred' }]); - - await expect(service.verifyRegistration(challengeId, mockCredential as any)).rejects.toThrow( - ConflictException - ); - }); - }); - - // ============================================================================ - // generateAuthenticationOptions - // ============================================================================ - - describe('generateAuthenticationOptions', () => { - it('should return options and challengeId (discoverable credentials)', async () => { - const mockOptions = { - challenge: 'auth-challenge', - rpId: 'localhost', - }; - - (generateAuthenticationOptions as jest.Mock).mockResolvedValue(mockOptions); - - const result = await service.generateAuthenticationOptions(); - - expect(result.options).toEqual(mockOptions); - expect(result.challengeId).toBe('mock-nanoid-id'); - expect(generateAuthenticationOptions).toHaveBeenCalledWith({ - rpID: 'localhost', - userVerification: 'preferred', - }); - }); - }); - - // ============================================================================ - // verifyAuthentication - // ============================================================================ - - describe('verifyAuthentication', () => { - const mockAuthCredential = { - id: 'cred-id-123', - rawId: 'raw-id', - response: { authenticatorData: 'data', clientDataJSON: 'json', signature: 'sig' }, - type: 'public-key', - }; - - it('should return user on successful authentication', async () => { - // Store challenge - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'auth-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - const mockPasskey = { - id: 'pk-123', - userId: 'user-123', - credentialId: 'cred-id-123', - publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), - counter: 5, - transports: ['internal'], - }; - - const mockUser = { - id: 'user-123', - email: 'test@example.com', - name: 'Test User', - deletedAt: null, - }; - - (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ - verified: true, - authenticationInfo: { newCounter: 6 }, - }); - - // find passkey, update counter, get user - mockDb.setResults([mockPasskey], [], [mockUser]); - - const result = await service.verifyAuthentication(challengeId, mockAuthCredential as any); - - expect(result.user).toEqual(mockUser); - expect(result.passkeyId).toBe('pk-123'); - expect(verifyAuthenticationResponse).toHaveBeenCalledWith( - expect.objectContaining({ - expectedChallenge: 'auth-challenge', - expectedOrigin: ['http://localhost:5173'], - expectedRPID: 'localhost', - }) - ); - }); - - it('should update counter and lastUsedAt', async () => { - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'auth-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - const mockPasskey = { - id: 'pk-123', - userId: 'user-123', - credentialId: 'cred-id-123', - publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), - counter: 5, - transports: [], - }; - - const mockUser = { id: 'user-123', email: 'test@example.com', deletedAt: null }; - - (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ - verified: true, - authenticationInfo: { newCounter: 10 }, - }); - - mockDb.setResults([mockPasskey], [], [mockUser]); - - await service.verifyAuthentication(challengeId, mockAuthCredential as any); - - // Verify update was called (set is chained) - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith( - expect.objectContaining({ - counter: 10, - }) - ); - }); - - it('should throw BadRequestException for unknown credential', async () => { - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'auth-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - // No passkey found - mockDb.setResults([]); - - await expect( - service.verifyAuthentication(challengeId, mockAuthCredential as any) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException for expired challenge', async () => { - await expect( - service.verifyAuthentication('invalid-challenge', mockAuthCredential as any) - ).rejects.toThrow(BadRequestException); - }); - - it('should throw BadRequestException for deleted user', async () => { - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'auth-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - const mockPasskey = { - id: 'pk-123', - userId: 'user-123', - credentialId: 'cred-id-123', - publicKey: Buffer.from([1, 2, 3, 4]).toString('base64url'), - counter: 5, - transports: [], - }; - - const deletedUser = { - id: 'user-123', - email: 'test@example.com', - deletedAt: new Date(), - }; - - (verifyAuthenticationResponse as jest.Mock).mockResolvedValue({ - verified: true, - authenticationInfo: { newCounter: 6 }, - }); - - mockDb.setResults([mockPasskey], [], [deletedUser]); - - await expect( - service.verifyAuthentication(challengeId, mockAuthCredential as any) - ).rejects.toThrow(BadRequestException); - }); - }); - - // ============================================================================ - // listPasskeys - // ============================================================================ - - describe('listPasskeys', () => { - it('should return all passkeys for a user', async () => { - const mockPasskeys = [ - { - id: 'pk-1', - credentialId: 'cred-1', - deviceType: 'multiPlatform', - backedUp: true, - friendlyName: 'My Key', - lastUsedAt: null, - createdAt: new Date(), - }, - { - id: 'pk-2', - credentialId: 'cred-2', - deviceType: 'singleDevice', - backedUp: false, - friendlyName: null, - lastUsedAt: new Date(), - createdAt: new Date(), - }, - ]; - - mockDb.setResults(mockPasskeys); - - const result = await service.listPasskeys('user-123'); - - expect(result).toEqual(mockPasskeys); - expect(mockDb.select).toHaveBeenCalled(); - expect(mockDb.from).toHaveBeenCalled(); - expect(mockDb.where).toHaveBeenCalled(); - }); - - it('should return empty array for user with no passkeys', async () => { - mockDb.setResults([]); - - const result = await service.listPasskeys('user-no-passkeys'); - - expect(result).toEqual([]); - }); - }); - - // ============================================================================ - // deletePasskey - // ============================================================================ - - describe('deletePasskey', () => { - it('should delete passkey owned by user', async () => { - const mockPasskey = { id: 'pk-123', userId: 'user-123', credentialId: 'cred-1' }; - - // First call: find passkey, second call: delete - mockDb.setResults([mockPasskey], []); - - await service.deletePasskey('user-123', 'pk-123'); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it('should throw NotFoundException for non-existent passkey', async () => { - mockDb.setResults([]); - - await expect(service.deletePasskey('user-123', 'nonexistent')).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // renamePasskey - // ============================================================================ - - describe('renamePasskey', () => { - it('should update friendly name', async () => { - const mockPasskey = { id: 'pk-123', userId: 'user-123', friendlyName: 'Old Name' }; - - mockDb.setResults([mockPasskey], []); - - await service.renamePasskey('user-123', 'pk-123', 'New Name'); - - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith({ friendlyName: 'New Name' }); - }); - - it('should throw NotFoundException for non-existent passkey', async () => { - mockDb.setResults([]); - - await expect(service.renamePasskey('user-123', 'nonexistent', 'Name')).rejects.toThrow( - NotFoundException - ); - }); - }); - - // ============================================================================ - // Challenge management - // ============================================================================ - - describe('Challenge management', () => { - it('should clean up expired challenges (5-minute TTL)', async () => { - // Generate a challenge - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'temp-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - // Advance time past the 5-minute TTL - jest.advanceTimersByTime(5 * 60 * 1000 + 1); - - // The challenge should now be expired - mockDb.setResults([]); - - await expect( - service.verifyAuthentication(challengeId, { id: 'cred' } as any) - ).rejects.toThrow(BadRequestException); - }); - - it('should consume challenge on use (one-time use)', async () => { - (generateAuthenticationOptions as jest.Mock).mockResolvedValue({ - challenge: 'one-time-challenge', - }); - const { challengeId } = await service.generateAuthenticationOptions(); - - // First use: passkey not found (throws different error), but challenge is consumed - mockDb.setResults([]); - - await expect( - service.verifyAuthentication(challengeId, { id: 'cred' } as any) - ).rejects.toThrow(BadRequestException); - - // Second use: challenge already consumed - await expect( - service.verifyAuthentication(challengeId, { id: 'cred' } as any) - ).rejects.toThrow(BadRequestException); - }); - }); -}); diff --git a/services/mana-core-auth/src/auth/services/passkey.service.ts b/services/mana-core-auth/src/auth/services/passkey.service.ts deleted file mode 100644 index 2a9d7beb7..000000000 --- a/services/mana-core-auth/src/auth/services/passkey.service.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { - Injectable, - NotFoundException, - BadRequestException, - ConflictException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - generateRegistrationOptions, - verifyRegistrationResponse, - generateAuthenticationOptions, - verifyAuthenticationResponse, -} from '@simplewebauthn/server'; -import type { - RegistrationResponseJSON, - AuthenticationResponseJSON, - AuthenticatorTransportFuture, -} from '@simplewebauthn/server'; -import { getDb } from '../../db/connection'; -import { passkeys, users } from '../../db/schema'; -import { eq, and } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; -import { LoggerService } from '../../common/logger'; - -interface ChallengeEntry { - challenge: string; - userId?: string; // Only set for registration - expiresAt: number; -} - -@Injectable() -export class PasskeyService { - private readonly logger: LoggerService; - private readonly challenges = new Map(); - private readonly rpID: string; - private readonly rpName = 'ManaCore'; - private readonly expectedOrigins: string[]; - private readonly databaseUrl: string; - - constructor( - private readonly configService: ConfigService, - loggerService: LoggerService - ) { - this.logger = loggerService.setContext('PasskeyService'); - this.databaseUrl = this.configService.get('database.url', ''); - this.rpID = this.configService.get('WEBAUTHN_RP_ID', 'localhost'); - - const originsStr = this.configService.get('WEBAUTHN_ORIGINS', ''); - this.expectedOrigins = originsStr - ? originsStr.split(',').map((o) => o.trim()) - : ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3001']; - - // Clean up expired challenges every 5 minutes - setInterval(() => this.cleanupChallenges(), 5 * 60 * 1000); - } - - private getDb() { - return getDb(this.databaseUrl); - } - - private cleanupChallenges() { - const now = Date.now(); - for (const [key, entry] of this.challenges) { - if (entry.expiresAt < now) { - this.challenges.delete(key); - } - } - } - - private storeChallenge(challengeId: string, challenge: string, userId?: string) { - this.challenges.set(challengeId, { - challenge, - userId, - expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes - }); - } - - private getAndDeleteChallenge(challengeId: string): ChallengeEntry | null { - const entry = this.challenges.get(challengeId); - if (!entry) return null; - this.challenges.delete(challengeId); - if (entry.expiresAt < Date.now()) return null; - return entry; - } - - /** - * Generate registration options for a logged-in user - */ - async generateRegistrationOptions(userId: string) { - const db = this.getDb(); - - // Get user - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - if (!user) throw new NotFoundException('User not found'); - - // Get existing passkeys to exclude - const existingPasskeys = await db.select().from(passkeys).where(eq(passkeys.userId, userId)); - - const excludeCredentials = existingPasskeys.map((pk) => ({ - id: pk.credentialId, - transports: (pk.transports as AuthenticatorTransportFuture[]) || [], - })); - - const options = await generateRegistrationOptions({ - rpName: this.rpName, - rpID: this.rpID, - userName: user.email, - userDisplayName: user.name || user.email, - attestationType: 'none', - excludeCredentials, - authenticatorSelection: { - residentKey: 'preferred', - userVerification: 'preferred', - }, - }); - - // Store challenge - const challengeId = nanoid(); - this.storeChallenge(challengeId, options.challenge, userId); - - return { options, challengeId }; - } - - /** - * Verify registration response and store the new passkey - */ - async verifyRegistration( - challengeId: string, - credential: RegistrationResponseJSON, - friendlyName?: string - ) { - const entry = this.getAndDeleteChallenge(challengeId); - if (!entry || !entry.userId) { - throw new BadRequestException('Invalid or expired challenge'); - } - - const verification = await verifyRegistrationResponse({ - response: credential, - expectedChallenge: entry.challenge, - expectedOrigin: this.expectedOrigins, - expectedRPID: this.rpID, - }); - - if (!verification.verified || !verification.registrationInfo) { - throw new BadRequestException('Passkey verification failed'); - } - - const { - credential: cred, - credentialDeviceType, - credentialBackedUp, - } = verification.registrationInfo; - - const db = this.getDb(); - - // Check for duplicate - const [existing] = await db - .select() - .from(passkeys) - .where(eq(passkeys.credentialId, cred.id)) - .limit(1); - - if (existing) { - throw new ConflictException('This passkey is already registered'); - } - - const id = nanoid(); - const [newPasskey] = await db - .insert(passkeys) - .values({ - id, - userId: entry.userId, - credentialId: cred.id, - publicKey: Buffer.from(cred.publicKey).toString('base64url'), - counter: cred.counter, - deviceType: credentialDeviceType, - backedUp: credentialBackedUp, - transports: cred.transports || [], - friendlyName: friendlyName || null, - }) - .returning(); - - this.logger.log(`Passkey registered for user ${entry.userId}: ${id}`); - - return { - id: newPasskey.id, - credentialId: newPasskey.credentialId, - deviceType: newPasskey.deviceType, - friendlyName: newPasskey.friendlyName, - createdAt: newPasskey.createdAt, - }; - } - - /** - * Generate authentication options (public - no auth required) - */ - async generateAuthenticationOptions() { - // Use discoverable credentials (resident keys) - no allowCredentials needed - // The browser will show all available passkeys for this rpID - const options = await generateAuthenticationOptions({ - rpID: this.rpID, - userVerification: 'preferred', - }); - - const challengeId = nanoid(); - this.storeChallenge(challengeId, options.challenge); - - return { options, challengeId }; - } - - /** - * Verify authentication response and return the user - */ - async verifyAuthentication(challengeId: string, credential: AuthenticationResponseJSON) { - const entry = this.getAndDeleteChallenge(challengeId); - if (!entry) { - throw new BadRequestException('Invalid or expired challenge'); - } - - const db = this.getDb(); - - // Find the passkey by credential ID - const [passkey] = await db - .select() - .from(passkeys) - .where(eq(passkeys.credentialId, credential.id)) - .limit(1); - - if (!passkey) { - throw new BadRequestException('Passkey not found'); - } - - const verification = await verifyAuthenticationResponse({ - response: credential, - expectedChallenge: entry.challenge, - expectedOrigin: this.expectedOrigins, - expectedRPID: this.rpID, - credential: { - id: passkey.credentialId, - publicKey: Buffer.from(passkey.publicKey, 'base64url'), - counter: passkey.counter, - transports: (passkey.transports as AuthenticatorTransportFuture[]) || [], - }, - }); - - if (!verification.verified) { - throw new BadRequestException('Passkey authentication failed'); - } - - // Update counter and lastUsedAt - await db - .update(passkeys) - .set({ - counter: verification.authenticationInfo.newCounter, - lastUsedAt: new Date(), - }) - .where(eq(passkeys.id, passkey.id)); - - // Get user - const [user] = await db.select().from(users).where(eq(users.id, passkey.userId)).limit(1); - - if (!user) { - throw new BadRequestException('User not found'); - } - - if (user.deletedAt) { - throw new BadRequestException('Account has been deleted'); - } - - return { user, passkeyId: passkey.id }; - } - - /** - * List all passkeys for a user - */ - async listPasskeys(userId: string) { - const db = this.getDb(); - const userPasskeys = await db - .select({ - id: passkeys.id, - credentialId: passkeys.credentialId, - deviceType: passkeys.deviceType, - backedUp: passkeys.backedUp, - friendlyName: passkeys.friendlyName, - lastUsedAt: passkeys.lastUsedAt, - createdAt: passkeys.createdAt, - }) - .from(passkeys) - .where(eq(passkeys.userId, userId)); - - return userPasskeys; - } - - /** - * Delete a passkey - */ - async deletePasskey(userId: string, passkeyId: string) { - const db = this.getDb(); - - const [passkey] = await db - .select() - .from(passkeys) - .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId))) - .limit(1); - - if (!passkey) { - throw new NotFoundException('Passkey not found'); - } - - await db.delete(passkeys).where(eq(passkeys.id, passkeyId)); - - this.logger.log(`Passkey deleted: ${passkeyId} for user ${userId}`); - } - - /** - * Rename a passkey - */ - async renamePasskey(userId: string, passkeyId: string, friendlyName: string) { - const db = this.getDb(); - - const [passkey] = await db - .select() - .from(passkeys) - .where(and(eq(passkeys.id, passkeyId), eq(passkeys.userId, userId))) - .limit(1); - - if (!passkey) { - throw new NotFoundException('Passkey not found'); - } - - await db.update(passkeys).set({ friendlyName }).where(eq(passkeys.id, passkeyId)); - } -} diff --git a/services/mana-core-auth/src/auth/sso-config.spec.ts b/services/mana-core-auth/src/auth/sso-config.spec.ts deleted file mode 100644 index e62625d5e..000000000 --- a/services/mana-core-auth/src/auth/sso-config.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * SSO Configuration Tests - * - * Validates that the Better Auth configuration correctly supports - * cross-subdomain SSO for all apps in the monorepo. - * - * These tests ensure that: - * 1. All active apps are listed in trustedOrigins - * 2. Cookie domain configuration is correct for SSO - * 3. CORS_ORIGINS in docker-compose matches trustedOrigins - */ - -import * as fs from 'fs'; -import * as path from 'path'; - -describe('SSO Configuration', () => { - const configPath = path.resolve(__dirname, 'better-auth.config.ts'); - let configContent: string; - - beforeAll(() => { - configContent = fs.readFileSync(configPath, 'utf8'); - }); - - describe('trustedOrigins', () => { - /** - * All apps that have a web frontend with SSO support. - * When adding a new app, add it here AND to trustedOrigins in better-auth.config.ts. - */ - const APPS_WITH_SSO = [ - 'calendar', - 'chat', - 'clock', - 'contacts', - 'context', - 'manadeck', - 'matrix', - 'mukke', - 'nutriphi', - 'photos', - 'picture', - 'planta', - 'presi', - 'questions', - 'skilltree', - 'storage', - 'todo', - 'traces', - 'zitare', - ]; - - it.each(APPS_WITH_SSO)('should include %s.mana.how in trustedOrigins', (appName) => { - expect(configContent).toContain(`https://${appName}.mana.how`); - }); - - it('should include the auth service itself', () => { - expect(configContent).toContain('https://auth.mana.how'); - }); - - it('should include the main domain', () => { - expect(configContent).toContain('https://mana.how'); - }); - - it('should include localhost for development', () => { - expect(configContent).toContain('http://localhost:5173'); - expect(configContent).toContain('http://localhost:3001'); - }); - }); - - describe('cookie configuration', () => { - it('should use "mana" cookie prefix', () => { - expect(configContent).toContain("cookiePrefix: 'mana'"); - }); - - it('should enable crossSubDomainCookies based on COOKIE_DOMAIN env', () => { - expect(configContent).toContain('enabled: !!process.env.COOKIE_DOMAIN'); - }); - - it('should use COOKIE_DOMAIN for the cookie domain', () => { - expect(configContent).toContain('domain: process.env.COOKIE_DOMAIN'); - }); - - it('should use sameSite lax for cross-subdomain navigation', () => { - expect(configContent).toContain("sameSite: 'lax'"); - }); - - it('should set httpOnly to protect cookies from JS access', () => { - expect(configContent).toContain('httpOnly: true'); - }); - }); - - describe('docker-compose alignment', () => { - const dockerComposePath = path.resolve(__dirname, '../../../../docker-compose.macmini.yml'); - - it('should have COOKIE_DOMAIN set to .mana.how in production docker-compose', () => { - if (!fs.existsSync(dockerComposePath)) { - // Skip if docker-compose not available (e.g., in CI) - return; - } - const dockerContent = fs.readFileSync(dockerComposePath, 'utf8'); - expect(dockerContent).toContain('COOKIE_DOMAIN: .mana.how'); - }); - - it('should have CORS_ORIGINS in docker-compose for mana-auth', () => { - if (!fs.existsSync(dockerComposePath)) { - return; - } - const dockerContent = fs.readFileSync(dockerComposePath, 'utf8'); - // All SSO apps should be in the CORS_ORIGINS - const appsToCheck = [ - 'calendar', - 'chat', - 'clock', - 'contacts', - 'mukke', - 'nutriphi', - 'photos', - 'picture', - 'planta', - 'presi', - 'questions', - 'skilltree', - 'storage', - 'todo', - 'zitare', - ]; - - for (const app of appsToCheck) { - expect(dockerContent).toContain(`https://${app}.mana.how`); - } - }); - }); -}); - -describe('sessionToToken cookie detection', () => { - it('should look for mana-prefixed cookies when COOKIE_DOMAIN is set', () => { - const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts'); - const serviceContent = fs.readFileSync(servicePath, 'utf8'); - - // Verify cookie name detection logic - expect(serviceContent).toContain( - "const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'" - ); - expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token'); - expect(serviceContent).toContain('${cookiePrefix}.session_token'); - }); -}); diff --git a/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts b/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts deleted file mode 100644 index 0fd400d2d..000000000 --- a/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * SSO sessionToToken Contract Tests - * - * Validates the session-to-token exchange logic that powers cross-app SSO. - * Tests cookie name detection, which is the critical piece that must match - * between the client (trySSO) and server (sessionToToken). - * - * Flow: - * 1. User logs in on app A → session cookie set with Domain=.mana.how - * 2. User visits app B → browser sends the session cookie - * 3. App B calls GET /api/auth/get-session (credentials: include) - * 4. App B calls POST /api/v1/auth/session-to-token → gets JWT tokens - * 5. JWT tokens stored in localStorage → user is authenticated - */ - -import * as fs from 'fs'; -import * as path from 'path'; - -describe('SSO sessionToToken contract', () => { - const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts'); - const authServiceClientPath = path.resolve( - __dirname, - '../../../../packages/shared-auth/src/core/authService.ts' - ); - let serviceContent: string; - let clientContent: string; - - beforeAll(() => { - serviceContent = fs.readFileSync(servicePath, 'utf8'); - clientContent = fs.readFileSync(authServiceClientPath, 'utf8'); - }); - - describe('cookie name detection (server side)', () => { - it('should use "mana" prefix when COOKIE_DOMAIN is set', () => { - // The server determines the cookie name based on COOKIE_DOMAIN - expect(serviceContent).toContain( - "const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'" - ); - }); - - it('should check both __Secure- and non-secure cookie names', () => { - expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token'); - expect(serviceContent).toContain('${cookiePrefix}.session_token'); - }); - - it('should try the secure cookie first, then fallback', () => { - // The order matters: __Secure- prefix is used in production (HTTPS) - expect(serviceContent).toContain( - 'req.cookies?.[sessionCookieName] || req.cookies?.[fallbackCookieName]' - ); - }); - }); - - describe('client-server contract alignment', () => { - it('client should call get-session with credentials: include', () => { - expect(clientContent).toContain("credentials: 'include'"); - expect(clientContent).toContain("method: 'GET'"); - // The get-session endpoint - expect(clientContent).toContain('endpoints.getSession'); - }); - - it('client should call session-to-token with credentials: include', () => { - expect(clientContent).toContain('/api/v1/auth/session-to-token'); - // Check that the session-to-token call uses credentials: include - const tokenFetchMatch = clientContent.match(/session-to-token.*?credentials:\s*'include'/s); - expect(tokenFetchMatch).not.toBeNull(); - }); - - it('server should expose session-to-token endpoint', () => { - const controllerPath = path.resolve(__dirname, 'auth.controller.ts'); - const controllerContent = fs.readFileSync(controllerPath, 'utf8'); - expect(controllerContent).toContain('sessionToToken'); - }); - - it('client should store accessToken and refreshToken from response', () => { - expect(clientContent).toContain('tokenData.accessToken'); - expect(clientContent).toContain('tokenData.refreshToken'); - }); - - it('server should return accessToken and refreshToken', () => { - // The service method should return an object with these fields - expect(serviceContent).toContain('accessToken'); - expect(serviceContent).toContain('refreshToken'); - }); - }); - - describe('SSO error handling', () => { - it('client should handle get-session failure gracefully', () => { - expect(clientContent).toContain('No SSO session found'); - }); - - it('client should handle token exchange failure gracefully', () => { - expect(clientContent).toContain('Token exchange not available'); - }); - - it('client should handle missing tokens in response', () => { - expect(clientContent).toContain('Invalid token response'); - }); - - it('client should catch and return network errors', () => { - // trySSO should not throw - it returns { success: false, error: ... } - expect(clientContent).toContain('SSO check failed'); - }); - - it('server should throw UnauthorizedException when no cookie found', () => { - expect(serviceContent).toContain('No session cookie found'); - expect(serviceContent).toContain('UnauthorizedException'); - }); - - it('server should throw UnauthorizedException for invalid sessions', () => { - expect(serviceContent).toContain('Invalid or expired session'); - }); - }); - - describe('main.ts route configuration', () => { - it('should exclude get-session from global API prefix', () => { - const mainPath = path.resolve(__dirname, '../main.ts'); - const mainContent = fs.readFileSync(mainPath, 'utf8'); - // get-session must be excluded from the /api/v1 prefix because - // Better Auth serves it at /api/auth/get-session (not /api/v1/api/auth/get-session) - expect(mainContent).toContain('api/auth/get-session'); - }); - }); -}); - -describe('SSO cookie configuration alignment', () => { - it('cookie prefix in config should match cookie detection in sessionToToken', () => { - const configPath = path.resolve(__dirname, 'better-auth.config.ts'); - const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts'); - - const configContent = fs.readFileSync(configPath, 'utf8'); - const serviceContent = fs.readFileSync(servicePath, 'utf8'); - - // Config sets cookiePrefix to 'mana' - expect(configContent).toContain("cookiePrefix: 'mana'"); - - // sessionToToken uses 'mana' when COOKIE_DOMAIN is set - // This must match! If config uses 'mana' but detection uses something else, SSO breaks. - expect(serviceContent).toContain("process.env.COOKIE_DOMAIN ? 'mana'"); - }); - - it('.env.example should document COOKIE_DOMAIN', () => { - const envExamplePath = path.resolve(__dirname, '../../.env.example'); - const envContent = fs.readFileSync(envExamplePath, 'utf8'); - expect(envContent).toContain('COOKIE_DOMAIN'); - expect(envContent).toContain('.mana.how'); - }); -}); diff --git a/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts b/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts deleted file mode 100644 index e51a007ec..000000000 --- a/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Password Reset Redirect Store - * - * Temporary in-memory store for tracking which app a user requested - * password reset from. This allows redirecting users back to the correct - * app's reset-password page after clicking the email link. - * - * TTL: 1 hour (password reset tokens are short-lived) - */ - -interface ResetRedirectEntry { - redirectUrl: string; - expiresAt: number; -} - -// In-memory store: email -> { redirectUrl, expiresAt } -const store = new Map(); - -// TTL in milliseconds (1 hour) -const TTL_MS = 60 * 60 * 1000; - -// Cleanup interval (every 15 minutes) -const CLEANUP_INTERVAL_MS = 15 * 60 * 1000; - -// Start cleanup interval -setInterval(() => { - const now = Date.now(); - for (const [email, entry] of store.entries()) { - if (entry.expiresAt < now) { - store.delete(email); - } - } -}, CLEANUP_INTERVAL_MS); - -export const passwordResetRedirectStore = { - /** - * Store the redirect URL for a password reset request - */ - set(email: string, redirectUrl: string): void { - const normalizedEmail = email.toLowerCase().trim(); - store.set(normalizedEmail, { - redirectUrl, - expiresAt: Date.now() + TTL_MS, - }); - }, - - /** - * Get the redirect URL for an email - * Returns null if not found or expired - */ - get(email: string): string | null { - const normalizedEmail = email.toLowerCase().trim(); - const entry = store.get(normalizedEmail); - - if (!entry) { - return null; - } - - // Check if expired - if (entry.expiresAt < Date.now()) { - store.delete(normalizedEmail); - return null; - } - - return entry.redirectUrl; - }, - - /** - * Get and remove the redirect URL for an email - * This is used after the user clicks the link to prevent re-use - */ - getAndDelete(email: string): string | null { - const normalizedEmail = email.toLowerCase().trim(); - const entry = store.get(normalizedEmail); - - if (!entry) { - return null; - } - - store.delete(normalizedEmail); - - // Check if expired - if (entry.expiresAt < Date.now()) { - return null; - } - - return entry.redirectUrl; - }, - - /** - * Clear all entries (for testing) - */ - clear(): void { - store.clear(); - }, -}; diff --git a/services/mana-core-auth/src/auth/stores/source-app.store.ts b/services/mana-core-auth/src/auth/stores/source-app.store.ts deleted file mode 100644 index 05c194f17..000000000 --- a/services/mana-core-auth/src/auth/stores/source-app.store.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Source App Store - * - * Temporary in-memory store for tracking which app a user registered from. - * This allows redirecting users back to the correct app's login page - * after email verification. - * - * TTL: 24 hours (matches verification token expiry) - */ - -interface SourceAppEntry { - sourceAppUrl: string; - expiresAt: number; -} - -// In-memory store: email -> { sourceAppUrl, expiresAt } -const store = new Map(); - -// TTL in milliseconds (24 hours) -const TTL_MS = 24 * 60 * 60 * 1000; - -// Cleanup interval (every hour) -const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; - -// Start cleanup interval -setInterval(() => { - const now = Date.now(); - for (const [email, entry] of store.entries()) { - if (entry.expiresAt < now) { - store.delete(email); - } - } -}, CLEANUP_INTERVAL_MS); - -export const sourceAppStore = { - /** - * Store the source app URL for an email - */ - set(email: string, sourceAppUrl: string): void { - const normalizedEmail = email.toLowerCase().trim(); - store.set(normalizedEmail, { - sourceAppUrl, - expiresAt: Date.now() + TTL_MS, - }); - }, - - /** - * Get the source app URL for an email - * Returns null if not found or expired - */ - get(email: string): string | null { - const normalizedEmail = email.toLowerCase().trim(); - const entry = store.get(normalizedEmail); - - if (!entry) { - return null; - } - - // Check if expired - if (entry.expiresAt < Date.now()) { - store.delete(normalizedEmail); - return null; - } - - return entry.sourceAppUrl; - }, - - /** - * Get and remove the source app URL for an email - * This is used after verification to prevent re-use - */ - getAndDelete(email: string): string | null { - const normalizedEmail = email.toLowerCase().trim(); - const entry = store.get(normalizedEmail); - - if (!entry) { - return null; - } - - store.delete(normalizedEmail); - - // Check if expired - if (entry.expiresAt < Date.now()) { - return null; - } - - return entry.sourceAppUrl; - }, - - /** - * Remove entry for an email - */ - delete(email: string): void { - const normalizedEmail = email.toLowerCase().trim(); - store.delete(normalizedEmail); - }, - - /** - * Clear all entries (for testing) - */ - clear(): void { - store.clear(); - }, -}; diff --git a/services/mana-core-auth/src/auth/types/better-auth.types.ts b/services/mana-core-auth/src/auth/types/better-auth.types.ts deleted file mode 100644 index 16d77e1ba..000000000 --- a/services/mana-core-auth/src/auth/types/better-auth.types.ts +++ /dev/null @@ -1,602 +0,0 @@ -/** - * Better Auth Type Definitions - * - * This file provides types for Better Auth integration. - * - * STRATEGY: Import base types from Better Auth packages, extend only when needed. - * - * From 'better-auth/types': - * - User, Session, Account, Auth, BetterAuthOptions, etc. - * - * From 'better-auth/plugins/organization': - * - Organization, Member, Invitation, OrganizationRole, InvitationStatus - * - * This file defines: - * 1. Extended types (adding fields Better Auth doesn't have) - * 2. API response/request types for our service layer - * 3. Service-specific DTOs and result types - * 4. Type guards for runtime safety - * - * @see https://www.better-auth.com/docs/concepts/typescript - * @see https://www.better-auth.com/docs/plugins/organization - */ - -// ============================================================================= -// Import core types from Better Auth packages -// ============================================================================= -import type { User, Session } from 'better-auth/types'; -import type { - Organization as BetterAuthOrganization, - Member as BetterAuthMember, - Invitation as BetterAuthInvitation, - OrganizationRole as BetterAuthOrganizationRole, - InvitationStatus as BetterAuthInvitationStatus, -} from 'better-auth/plugins/organization'; - -// Re-export base types for convenience -export type { User, Session }; -export type { - BetterAuthOrganization, - BetterAuthMember, - BetterAuthInvitation, - BetterAuthOrganizationRole, - BetterAuthInvitationStatus, -}; - -/** - * Extended User type with our additional fields - * Better Auth's User type is the base, we extend it for our app - */ -export interface BetterAuthUser extends User { - role?: string; -} - -/** - * Extended Session type with organization support - * Better Auth's Session type is the base, organization plugin adds activeOrganizationId - */ -export interface BetterAuthSession extends Session { - activeOrganizationId?: string | null; - metadata?: Record; -} - -/** - * JWT Payload context passed to definePayload - */ -export interface JWTPayloadContext { - user: BetterAuthUser; - session: BetterAuthSession; -} - -// ============================================================================= -// Organization Types (aligned with Better Auth but with explicit fields) -// ============================================================================= - -/** - * Organization entity - mirrors Better Auth's Organization type - * We define explicitly to ensure type safety in our service layer - */ -export interface Organization { - id: string; - name: string; - slug: string; - logo?: string | null; - metadata?: Record; - createdAt: Date; - updatedAt?: Date; -} - -/** - * Organization member - mirrors Better Auth's Member type - */ -export interface OrganizationMember { - id: string; - userId: string; - organizationId: string; - role: OrganizationRole; - createdAt: Date; - updatedAt?: Date; -} - -/** - * Organization role types - aligned with Better Auth defaults - */ -export type OrganizationRole = 'owner' | 'admin' | 'member'; - -/** - * Organization invitation - mirrors Better Auth's Invitation type - */ -export interface OrganizationInvitation { - id: string; - email: string; - organizationId: string; - role: OrganizationRole; - status: 'pending' | 'accepted' | 'rejected' | 'expired'; - inviterId: string; - expiresAt: Date; - createdAt: Date; -} - -// ============================================================================= -// API Response Types -// ============================================================================= - -/** - * Sign up response from Better Auth - */ -export interface SignUpResponse { - user: BetterAuthUser; - token?: string; - session?: BetterAuthSession; -} - -/** - * Sign in response from Better Auth - */ -export interface SignInResponse { - user: BetterAuthUser; - token: string; - session: BetterAuthSession; -} - -/** - * Create organization response - */ -export interface CreateOrganizationResponse extends Organization { - // Organization fields are returned directly -} - -/** - * Invite member response - */ -export interface InviteMemberResponse { - id: string; - email: string; - organizationId: string; - role: OrganizationRole; - status: 'pending'; - expiresAt: Date; -} - -/** - * Accept invitation response - */ -export interface AcceptInvitationResponse { - member: OrganizationMember; - organization: Organization; -} - -/** - * Get full organization response - */ -export interface GetFullOrganizationResponse extends Organization { - members: Array; - invitations?: OrganizationInvitation[]; -} - -/** - * Set active organization response - */ -export interface SetActiveOrganizationResponse { - userId: string; - activeOrganizationId: string; - metadata?: Record; - session?: BetterAuthSession; -} - -// ============================================================================= -// API Request Types -// ============================================================================= - -/** - * Sign up request body - */ -export interface SignUpEmailBody { - email: string; - password: string; - name: string; -} - -/** - * Create organization request body - */ -export interface CreateOrganizationBody { - name: string; - slug: string; - logo?: string; - metadata?: Record; -} - -/** - * Invite member request body - */ -export interface InviteMemberBody { - email: string; - role: OrganizationRole; - organizationId: string; -} - -/** - * Accept invitation request body - */ -export interface AcceptInvitationBody { - invitationId: string; -} - -/** - * Remove member request body - */ -export interface RemoveMemberBody { - memberIdOrEmail: string; - organizationId: string; -} - -/** - * Set active organization request body - */ -export interface SetActiveOrganizationBody { - organizationId: string; -} - -/** - * Get full organization query - */ -export interface GetFullOrganizationQuery { - organizationId?: string; - organizationSlug?: string; - membersLimit?: number; -} - -// ============================================================================= -// API Method Types (with headers) -// ============================================================================= - -export interface AuthenticatedRequest { - body?: TBody; - query?: TQuery; - headers: { - authorization: string; - }; -} - -// ============================================================================= -// Better Auth API Interface -// ============================================================================= - -/** - * Typed Better Auth API interface - * - * This interface describes the methods available on auth.api - * when using the organization plugin. - */ -export interface BetterAuthAPI { - // Core auth methods - signUpEmail(params: { body: SignUpEmailBody }): Promise; - signInEmail(params: { body: { email: string; password: string } }): Promise; - - // Organization methods - createOrganization( - params: AuthenticatedRequest - ): Promise; - - inviteMember(params: AuthenticatedRequest): Promise; - - acceptInvitation( - params: AuthenticatedRequest - ): Promise; - - getFullOrganization(params: { - query: GetFullOrganizationQuery; - }): Promise; - - removeMember(params: AuthenticatedRequest): Promise<{ success: boolean }>; - - setActiveOrganization( - params: AuthenticatedRequest - ): Promise; - - listOrganizations(params: AuthenticatedRequest): Promise; -} - -// ============================================================================= -// Service Response Types -// ============================================================================= - -/** - * B2C Registration result - */ -export interface RegisterB2CResult { - user: { - id: string; - email: string; - name: string | null; - }; - token?: string; -} - -/** - * B2B Registration result - */ -export interface RegisterB2BResult { - user: BetterAuthUser; - organization: Organization; - token: string; -} - -/** - * Invite employee result - */ -export interface InviteEmployeeResult { - id: string; - email: string; - organizationId: string; - role: OrganizationRole; - status: 'pending'; - expiresAt: Date; -} - -/** - * Accept invitation result - */ -export interface AcceptInvitationResult { - member: OrganizationMember; - organization?: Organization; - userId?: string; -} - -/** - * Remove member result - */ -export interface RemoveMemberResult { - success: boolean; - message: string; -} - -/** - * Set active organization result - * Returns session data with the active organization ID - */ -export interface SetActiveOrganizationResult { - userId: string; - activeOrganizationId: string; - metadata?: Record; - session?: BetterAuthSession; -} - -// ============================================================================= -// DTO Types (for NestJS controllers) -// ============================================================================= - -/** - * DTO for B2C user registration - */ -export interface RegisterB2CDto { - email: string; - password: string; - name: string; - sourceAppId?: string; - sourceAppUrl?: string; -} - -/** - * DTO for B2B organization registration - */ -export interface RegisterB2BDto { - ownerEmail: string; - password: string; - ownerName: string; - organizationName: string; -} - -/** - * DTO for employee invitation - */ -export interface InviteEmployeeDto { - organizationId: string; - employeeEmail: string; - role: 'admin' | 'member'; - inviterToken: string; -} - -/** - * DTO for accepting invitation - */ -export interface AcceptInvitationDto { - invitationId: string; - userToken: string; -} - -/** - * DTO for removing organization member - */ -export interface RemoveMemberDto { - organizationId: string; - memberId: string; - removerToken: string; -} - -/** - * DTO for setting active organization - */ -export interface SetActiveOrganizationDto { - organizationId: string; - userToken: string; -} - -/** - * DTO for user sign in - */ -export interface SignInDto { - email: string; - password: string; - deviceId?: string; - deviceName?: string; -} - -/** - * Sign in result - */ -export interface SignInResult { - user: { - id: string; - email: string; - name: string | null; - role?: string; - }; - accessToken: string; - refreshToken: string; - expiresIn: number; -} - -/** - * DTO for sign out - */ -export interface SignOutDto { - token: string; -} - -/** - * Sign out result - */ -export interface SignOutResult { - success: boolean; - message: string; -} - -/** - * Get session result - */ -export interface GetSessionResult { - user: BetterAuthUser; - session: BetterAuthSession; -} - -/** - * List user organizations result - */ -export interface ListOrganizationsResult { - organizations: Organization[]; -} - -/** - * DTO for refresh token - */ -export interface RefreshTokenDto { - refreshToken: string; -} - -/** - * Refresh token result - */ -export interface RefreshTokenResult { - user: { - id: string; - email: string; - name: string | null; - role?: string; - }; - accessToken: string; - refreshToken: string; - expiresIn: number; - tokenType: string; -} - -/** - * DTO for token validation - */ -export interface ValidateTokenDto { - token: string; -} - -/** - * Token payload structure (JWT claims) - */ -export interface TokenPayload { - sub: string; - email: string; - role: string; - sessionId: string; - deviceId?: string; - organizationId?: string; - iat?: number; - exp?: number; - iss?: string; - aud?: string | string[]; -} - -/** - * Validate token result - */ -export interface ValidateTokenResult { - valid: boolean; - payload?: TokenPayload; - error?: string; -} - -// ============================================================================= -// Type Guards -// ============================================================================= - -/** - * Type guard to check if response has user property - */ -export function hasUser(response: unknown): response is { user: BetterAuthUser } { - return ( - typeof response === 'object' && - response !== null && - 'user' in response && - typeof (response as { user: unknown }).user === 'object' - ); -} - -/** - * Type guard to check if response has token property - */ -export function hasToken(response: unknown): response is { token: string } { - return ( - typeof response === 'object' && - response !== null && - 'token' in response && - typeof (response as { token: unknown }).token === 'string' - ); -} - -/** - * Type guard to check if response has member property - */ -export function hasMember(response: unknown): response is { member: OrganizationMember } { - return ( - typeof response === 'object' && - response !== null && - 'member' in response && - typeof (response as { member: unknown }).member === 'object' - ); -} - -/** - * Type guard to check if response has members array - */ -export function hasMembers(response: unknown): response is { members: OrganizationMember[] } { - return ( - typeof response === 'object' && - response !== null && - 'members' in response && - Array.isArray((response as { members: unknown }).members) - ); -} - -/** - * Type guard to check if response has session property - */ -export function hasSession( - response: unknown -): response is { user: BetterAuthUser; session: BetterAuthSession } { - return ( - typeof response === 'object' && - response !== null && - 'user' in response && - 'session' in response && - typeof (response as { user: unknown }).user === 'object' && - typeof (response as { session: unknown }).session === 'object' - ); -} diff --git a/services/mana-core-auth/src/auth/types/index.ts b/services/mana-core-auth/src/auth/types/index.ts deleted file mode 100644 index 4cc6235a5..000000000 --- a/services/mana-core-auth/src/auth/types/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auth Types Index - * - * Re-exports all authentication-related types - */ - -export * from './better-auth.types'; diff --git a/services/mana-core-auth/src/common/decorators/current-user.decorator.ts b/services/mana-core-auth/src/common/decorators/current-user.decorator.ts deleted file mode 100644 index ad56a1912..000000000 --- a/services/mana-core-auth/src/common/decorators/current-user.decorator.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createParamDecorator } from '@nestjs/common'; -import type { ExecutionContext } from '@nestjs/common'; - -export interface CurrentUserData { - userId: string; - email: string; - role: string; -} - -export const CurrentUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext): CurrentUserData => { - const request = ctx.switchToHttp().getRequest(); - return request.user; - } -); diff --git a/services/mana-core-auth/src/common/filters/http-exception.filter.ts b/services/mana-core-auth/src/common/filters/http-exception.filter.ts deleted file mode 100644 index be7b276d1..000000000 --- a/services/mana-core-auth/src/common/filters/http-exception.filter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; -import { Response } from 'express'; - -@Catch() -export class HttpExceptionFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - let status = HttpStatus.INTERNAL_SERVER_ERROR; - let message = 'Internal server error'; - let errors: any = undefined; - - if (exception instanceof HttpException) { - status = exception.getStatus(); - const exceptionResponse = exception.getResponse(); - - if (typeof exceptionResponse === 'string') { - message = exceptionResponse; - } else if (typeof exceptionResponse === 'object') { - message = (exceptionResponse as any).message || message; - errors = (exceptionResponse as any).errors; - } - } else if (exception instanceof Error) { - message = exception.message; - } - - const errorResponse = { - statusCode: status, - message, - ...(errors && { errors }), - timestamp: new Date().toISOString(), - path: request.url, - }; - - response.status(status).json(errorResponse); - } -} diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts deleted file mode 100644 index e23353eff..000000000 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts +++ /dev/null @@ -1,533 +0,0 @@ -/** - * JwtAuthGuard Unit Tests - * - * Tests JWT authentication guard functionality: - * - Token extraction from Authorization header - * - JWT verification using locally cached JWKS (EdDSA keys) - * - Error handling for invalid/expired tokens - * - User attachment to request object - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { JwtAuthGuard } from './jwt-auth.guard'; -import { LoggerService } from '../logger'; -import { createMockConfigService, httpMockHelpers } from '../../__tests__/utils/test-helpers'; -import { mockTokenFactory } from '../../__tests__/utils/mock-factories'; -import { silentError } from '../../__tests__/utils/silent-error.decorator'; -import { jwtVerify } from 'jose'; -import { createCachedLocalJWKSet } from './local-jwks-cache'; - -// Mock jose (auto-mocked via jest.config.js moduleNameMapper) -jest.mock('jose'); - -// Mock the local JWKS cache -jest.mock('./local-jwks-cache'); - -// Setup mock for createCachedLocalJWKSet to return a defined JWKS function -const mockJWKS = jest.fn(); -const mockCreateLocalJWKSet = createCachedLocalJWKSet as jest.MockedFunction< - typeof createCachedLocalJWKSet ->; -mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any); - -// Mock LoggerService -const createMockLoggerService = (): LoggerService => - ({ - setContext: jest.fn().mockReturnThis(), - log: jest.fn(), - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - verbose: jest.fn(), - }) as unknown as LoggerService; - -describe('JwtAuthGuard', () => { - let guard: JwtAuthGuard; - let configService: ConfigService; - const mockJwtVerify = jwtVerify as jest.MockedFunction; - - beforeEach(async () => { - // Reset mocks - jest.clearAllMocks(); - - // Ensure createCachedLocalJWKSet returns a defined value after clearing - mockCreateLocalJWKSet.mockReturnValue(mockJWKS as any); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - JwtAuthGuard, - { - provide: ConfigService, - useValue: createMockConfigService({ - BASE_URL: 'http://localhost:3001', - 'jwt.issuer': 'manacore', - 'jwt.audience': 'manacore', - 'database.url': 'postgresql://localhost:5432/test', - }), - }, - { - provide: LoggerService, - useValue: createMockLoggerService(), - }, - ], - }).compile(); - - guard = module.get(JwtAuthGuard); - configService = module.get(ConfigService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('canActivate', () => { - it('should return true for valid JWT token', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload({ - sub: 'user-123', - email: 'test@example.com', - role: 'user', - }); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - const result = await guard.canActivate(mockContext as any); - - expect(result).toBe(true); - expect(mockRequest.user).toEqual({ - sub: 'user-123', - userId: 'user-123', - email: 'test@example.com', - role: 'user', - }); - }); - - it('should throw UnauthorizedException when no token provided', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: {}, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should throw UnauthorizedException when authorization header is missing', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - 'content-type': 'application/json', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should throw UnauthorizedException for expired token', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer expired-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const expiredError = new Error('JWT expired'); - (expiredError as any).code = 'ERR_JWT_EXPIRED'; - mockJwtVerify.mockRejectedValue(expiredError); - - await silentError(async () => { - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('Invalid token'); - }); - }); - - it('should throw UnauthorizedException for invalid token', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer invalid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const invalidError = new Error('JWT invalid'); - (invalidError as any).code = 'ERR_JWT_INVALID'; - mockJwtVerify.mockRejectedValue(invalidError); - - await silentError(async () => { - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('Invalid token'); - }); - }); - - it('should throw UnauthorizedException for malformed token', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer not.a.valid.jwt', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - mockJwtVerify.mockRejectedValue(new Error('Invalid compact JWS')); - - await silentError(async () => { - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - }); - }); - - it('should verify token with correct issuer and audience', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guard.canActivate(mockContext as any); - - // Note: issuer defaults to http://localhost:3001 when BASE_URL and jwt.issuer are not set - expect(mockJwtVerify).toHaveBeenCalledWith( - 'valid-jwt-token', - expect.anything(), // JWKS - expect.objectContaining({ - issuer: 'http://localhost:3001', - audience: 'manacore', - }) - ); - }); - - it('should attach complete user info to request', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload({ - sub: 'user-456', - email: 'admin@example.com', - role: 'admin', - }); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guard.canActivate(mockContext as any); - - expect(mockRequest.user).toEqual({ - sub: 'user-456', - userId: 'user-456', - email: 'admin@example.com', - role: 'admin', - }); - }); - - it('should initialize JWKS on first use', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - // First call initializes JWKS - await guard.canActivate(mockContext as any); - - expect(mockJwtVerify).toHaveBeenCalledTimes(1); - - // Second call reuses same JWKS - await guard.canActivate(mockContext as any); - - expect(mockJwtVerify).toHaveBeenCalledTimes(2); - }); - }); - - describe('extractTokenFromHeader', () => { - it('should extract token from Bearer authorization header', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer my-secret-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guard.canActivate(mockContext as any); - - expect(mockJwtVerify).toHaveBeenCalledWith( - 'my-secret-token', - expect.anything(), - expect.anything() - ); - }); - - it('should return undefined for non-Bearer authorization', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Basic user:pass', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should return undefined for empty authorization header', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: '', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should return undefined when authorization header is just "Bearer"', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - }); - - describe('Configuration', () => { - it('should use local JWKS cache for key resolution', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guard.canActivate(mockContext as any); - - // Should use createCachedLocalJWKSet instead of createRemoteJWKSet - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith( - expect.any(String) // database URL - ); - expect(mockJwtVerify).toHaveBeenCalled(); - }); - - it('should use default BASE_URL when not configured', async () => { - // Create guard with config missing BASE_URL - const guardWithDefaults = new JwtAuthGuard( - createMockConfigService({ - 'jwt.issuer': 'manacore', - 'jwt.audience': 'manacore', - 'database.url': 'postgresql://localhost:5432/test', - }), - createMockLoggerService() - ); - - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guardWithDefaults.canActivate(mockContext as any); - - // Should still work with default localhost URL - expect(mockJwtVerify).toHaveBeenCalled(); - }); - - it('should use configured issuer and audience', async () => { - // Note: issuer = baseUrl || jwtIssuer || default, so we don't set BASE_URL to test jwt.issuer - const guardWithCustomConfig = new JwtAuthGuard( - createMockConfigService({ - 'jwt.issuer': 'custom-issuer', - 'jwt.audience': 'custom-audience', - 'database.url': 'postgresql://localhost:5432/test', - }), - createMockLoggerService() - ); - - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - const mockPayload = mockTokenFactory.validPayload(); - - mockJwtVerify.mockResolvedValue({ - payload: mockPayload, - protectedHeader: { alg: 'EdDSA', typ: 'JWT' }, - key: {} as any, - }); - - await guardWithCustomConfig.canActivate(mockContext as any); - - expect(mockJwtVerify).toHaveBeenCalledWith( - 'valid-jwt-token', - expect.anything(), - expect.objectContaining({ - issuer: 'custom-issuer', - audience: 'custom-audience', - }) - ); - }); - }); - - describe('Security', () => { - it('should not accept tokens without Bearer prefix', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should handle case-sensitive Bearer prefix', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'bearer valid-jwt-token', // lowercase - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - // Should not accept lowercase "bearer" - await expect(guard.canActivate(mockContext as any)).rejects.toThrow('No token provided'); - }); - - it('should reject token with wrong issuer', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - mockJwtVerify.mockRejectedValue(new Error('unexpected "iss" claim value')); - - await silentError(async () => { - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - }); - }); - - it('should reject token with wrong audience', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer valid-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - mockJwtVerify.mockRejectedValue(new Error('unexpected "aud" claim value')); - - await silentError(async () => { - await expect(guard.canActivate(mockContext as any)).rejects.toThrow(UnauthorizedException); - }); - }); - - it('should not expose sensitive error details', async () => { - const mockRequest = httpMockHelpers.createMockRequest({ - headers: { - authorization: 'Bearer tampered-jwt-token', - }, - }); - - const mockContext = httpMockHelpers.createMockExecutionContext(mockRequest); - - mockJwtVerify.mockRejectedValue(new Error('signature verification failed')); - - await silentError(async () => { - try { - await guard.canActivate(mockContext as any); - fail('Should have thrown UnauthorizedException'); - } catch (error) { - expect(error).toBeInstanceOf(UnauthorizedException); - // Should not expose the specific jose error message - expect((error as any).message).toBe('Invalid token'); - } - }); - }); - }); -}); diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts deleted file mode 100644 index 541a1fb7a..000000000 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - Injectable, - type CanActivate, - type ExecutionContext, - UnauthorizedException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { jwtVerify } from 'jose'; -import { LoggerService } from '../logger'; -import { createCachedLocalJWKSet } from './local-jwks-cache'; - -/** - * JWT Auth Guard using local JWKS cache (Better Auth compatible) - * - * Uses jose library with locally cached JWKS keys for EdDSA token verification. - * Keys are read directly from the database instead of making HTTP requests - * to the service's own JWKS endpoint. - */ -@Injectable() -export class JwtAuthGuard implements CanActivate { - private jwks: ReturnType | null = null; - private readonly logger: LoggerService; - - constructor( - private configService: ConfigService, - loggerService: LoggerService - ) { - this.logger = loggerService.setContext('JwtAuthGuard'); - } - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromHeader(request); - - if (!token) { - throw new UnauthorizedException('No token provided'); - } - - try { - // Lazy initialize local JWKS (reads from DB, cached in memory) - if (!this.jwks) { - const databaseUrl = this.configService.get('database.url') || ''; - this.jwks = createCachedLocalJWKSet(databaseUrl); - } - - // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts) - // Better Auth uses: issuer = BASE_URL || JWT_ISSUER || 'http://localhost:3001' - const baseUrl = this.configService.get('BASE_URL'); - const jwtIssuer = this.configService.get('jwt.issuer'); - const issuer = baseUrl || jwtIssuer || 'http://localhost:3001'; - const audience = this.configService.get('jwt.audience') || 'manacore'; - - const { payload } = await jwtVerify(token, this.jwks, { - issuer, - audience, - }); - - this.logger.debug('Token verification successful', { userId: payload.sub }); - - // Attach user to request - // Include both 'sub' and 'userId' for compatibility with different controllers - request.user = { - sub: payload.sub, - userId: payload.sub, - email: payload.email as string, - role: payload.role as string, - }; - - return true; - } catch (error) { - this.logger.warn('Token verification failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw new UnauthorizedException('Invalid token'); - } - } - - private extractTokenFromHeader(request: any): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} diff --git a/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts b/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts deleted file mode 100644 index 7cbcc48b8..000000000 --- a/services/mana-core-auth/src/common/guards/local-jwks-cache.spec.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Local JWKS Cache Unit Tests - * - * Tests the in-memory JWKS cache that reads keys from the database - * and provides jose-compatible key resolvers for JWT verification. - * - * - Happy path: loads keys from DB, returns working resolver - * - Caching: returns cached result within TTL, refreshes after TTL - * - Empty DB: throws meaningful error when no keys available - * - DB failure: propagates errors with meaningful context - * - Key rotation: picks up new keys after cache expires - */ - -import { createCachedLocalJWKSet, clearJwksCache } from './local-jwks-cache'; - -// Mock the DB connection module -jest.mock('../../db/connection', () => ({ - getDb: jest.fn(), -})); - -// Mock jose - we test the cache logic, not jose internals -jest.mock('jose', () => ({ - createLocalJWKSet: jest.fn(), -})); - -import { getDb } from '../../db/connection'; -import { createLocalJWKSet } from 'jose'; - -const mockGetDb = getDb as jest.MockedFunction; -const mockCreateLocalJWKSet = createLocalJWKSet as jest.MockedFunction; - -// Sample EdDSA JWK for testing -const sampleJwk = { - kty: 'OKP', - crv: 'Ed25519', - x: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NA', - kid: 'test-key-1', -}; - -const sampleDbRow = { - id: 'test-key-1', - publicKey: JSON.stringify(sampleJwk), - privateKey: '{"kty":"OKP","crv":"Ed25519","d":"private","x":"dGVzdC1wdWJsaWMta2V5LWJhc2U2NA"}', - createdAt: new Date(), -}; - -describe('Local JWKS Cache', () => { - let mockFrom: jest.Mock; - let mockSelect: jest.Mock; - let mockResolver: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - clearJwksCache(); - - // Setup DB mock chain: db.select().from(jwks) => rows - mockFrom = jest.fn(); - mockSelect = jest.fn().mockReturnValue({ from: mockFrom }); - mockGetDb.mockReturnValue({ select: mockSelect } as any); - - // Setup jose mock resolver - mockResolver = jest.fn().mockResolvedValue({} as CryptoKey); - mockCreateLocalJWKSet.mockReturnValue(mockResolver as any); - }); - - describe('Happy path', () => { - it('should read JWKS from DB and return a working key resolver', async () => { - mockFrom.mockResolvedValue([sampleDbRow]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - // Should have queried the DB - expect(mockGetDb).toHaveBeenCalledWith('postgresql://localhost:5432/test'); - expect(mockSelect).toHaveBeenCalled(); - expect(mockFrom).toHaveBeenCalled(); - - // Should have created a local JWK set with the parsed keys - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [sampleJwk], - }); - - // Should have called the resolver - expect(mockResolver).toHaveBeenCalledWith({ alg: 'EdDSA' }, {}); - }); - - it('should set kid from row id when JWK has no kid', async () => { - const jwkWithoutKid = { kty: 'OKP', crv: 'Ed25519', x: 'abc123' }; - const row = { - id: 'row-id-123', - publicKey: JSON.stringify(jwkWithoutKid), - privateKey: '{}', - createdAt: new Date(), - }; - - mockFrom.mockResolvedValue([row]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [{ ...jwkWithoutKid, kid: 'row-id-123' }], - }); - }); - - it('should handle multiple keys from DB', async () => { - const secondJwk = { kty: 'OKP', crv: 'Ed25519', x: 'c2Vjb25kLWtleQ', kid: 'key-2' }; - const rows = [ - sampleDbRow, - { - id: 'key-2', - publicKey: JSON.stringify(secondJwk), - privateKey: '{}', - createdAt: new Date(), - }, - ]; - - mockFrom.mockResolvedValue(rows); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [sampleJwk, secondJwk], - }); - }); - - it('should skip malformed JSON keys without crashing', async () => { - const rows = [ - { id: 'bad-key', publicKey: 'not-valid-json{', privateKey: '{}', createdAt: new Date() }, - sampleDbRow, - ]; - - mockFrom.mockResolvedValue(rows); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - // Should only include the valid key - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [sampleJwk], - }); - }); - }); - - describe('Caching behavior', () => { - it('should use cached resolver on second call within TTL', async () => { - mockFrom.mockResolvedValue([sampleDbRow]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - // First call - reads from DB - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(1); - expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); - - // Second call - should use cache - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(1); // Still 1 - no new DB query - expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); // Still 1 - }); - - it('should refresh cache after TTL expires', async () => { - mockFrom.mockResolvedValue([sampleDbRow]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - // First call - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(1); - - // Advance time past TTL (5 minutes = 300000ms) - const originalDateNow = Date.now; - Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1); - - try { - // Third call after TTL - should refresh - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(2); // New DB query - expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2); // New resolver created - } finally { - Date.now = originalDateNow; - } - }); - - it('should not refresh cache before TTL expires', async () => { - mockFrom.mockResolvedValue([sampleDbRow]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - // First call - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - // Advance time to just before TTL (4 minutes 59 seconds) - const originalDateNow = Date.now; - Date.now = jest.fn().mockReturnValue(originalDateNow() + 4 * 60 * 1000 + 59 * 1000); - - try { - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(1); // No refresh - } finally { - Date.now = originalDateNow; - } - }); - }); - - describe('Empty DB', () => { - it('should throw error when no JWKS keys are in the database', async () => { - mockFrom.mockResolvedValue([]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( - 'No JWKS keys available in database' - ); - }); - - it('should throw error when all keys have malformed JSON', async () => { - mockFrom.mockResolvedValue([ - { id: 'bad-1', publicKey: '{invalid', privateKey: '{}', createdAt: new Date() }, - { id: 'bad-2', publicKey: 'not json', privateKey: '{}', createdAt: new Date() }, - ]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( - 'No JWKS keys available in database' - ); - }); - }); - - describe('DB connection failure', () => { - it('should propagate database errors with meaningful context', async () => { - mockFrom.mockRejectedValue(new Error('Connection refused')); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( - 'Connection refused' - ); - }); - - it('should propagate timeout errors', async () => { - mockFrom.mockRejectedValue(new Error('Query timeout')); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow('Query timeout'); - }); - - it('should retry DB read after a failed attempt (no stale error cached)', async () => { - // First call fails - mockFrom.mockRejectedValueOnce(new Error('Connection refused')); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - await expect(keyGetter({ alg: 'EdDSA' } as any, {} as any)).rejects.toThrow( - 'Connection refused' - ); - - // Second call should try DB again (not cache the error) - mockFrom.mockResolvedValueOnce([sampleDbRow]); - - const result = await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(2); - expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(1); - }); - }); - - describe('Key rotation', () => { - it('should pick up new keys after cache TTL expires', async () => { - const originalKey = sampleDbRow; - const rotatedJwk = { kty: 'OKP', crv: 'Ed25519', x: 'cm90YXRlZC1rZXk', kid: 'rotated-key' }; - const rotatedRow = { - id: 'rotated-key', - publicKey: JSON.stringify(rotatedJwk), - privateKey: '{}', - createdAt: new Date(), - }; - - // First call returns original key - mockFrom.mockResolvedValueOnce([originalKey]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [sampleJwk], - }); - - // Expire the cache - const originalDateNow = Date.now; - Date.now = jest.fn().mockReturnValue(originalDateNow() + 5 * 60 * 1000 + 1); - - try { - // Second call returns rotated key - mockFrom.mockResolvedValueOnce([rotatedRow]); - - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - expect(mockCreateLocalJWKSet).toHaveBeenCalledTimes(2); - expect(mockCreateLocalJWKSet).toHaveBeenLastCalledWith({ - keys: [rotatedJwk], - }); - } finally { - Date.now = originalDateNow; - } - }); - - it('should serve both old and new keys during rotation period', async () => { - const oldJwk = sampleJwk; - const newJwk = { kty: 'OKP', crv: 'Ed25519', x: 'bmV3LWtleQ', kid: 'new-key' }; - - // DB returns both keys (typical during rotation) - mockFrom.mockResolvedValue([ - sampleDbRow, - { - id: 'new-key', - publicKey: JSON.stringify(newJwk), - privateKey: '{}', - createdAt: new Date(), - }, - ]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - - expect(mockCreateLocalJWKSet).toHaveBeenCalledWith({ - keys: [oldJwk, newJwk], - }); - }); - }); - - describe('clearJwksCache', () => { - it('should force a DB re-read on next call after clearing', async () => { - mockFrom.mockResolvedValue([sampleDbRow]); - - const keyGetter = createCachedLocalJWKSet('postgresql://localhost:5432/test'); - - // First call - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(1); - - // Clear cache - clearJwksCache(); - - // Next call should query DB again - await keyGetter({ alg: 'EdDSA' } as any, {} as any); - expect(mockFrom).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/services/mana-core-auth/src/common/guards/local-jwks-cache.ts b/services/mana-core-auth/src/common/guards/local-jwks-cache.ts deleted file mode 100644 index 0006aaf4a..000000000 --- a/services/mana-core-auth/src/common/guards/local-jwks-cache.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Local JWKS Cache - * - * Provides in-memory cached JWKS keys for JWT verification without - * making HTTP requests. Since the auth service IS the JWKS provider, - * it should read keys directly from the database instead of fetching - * from its own HTTP endpoint. - * - * Uses jose's built-in createLocalJWKSet() for key resolution, - * wrapping it with a database-backed cache layer. - */ - -import { createLocalJWKSet as joseCreateLocalJWKSet } from 'jose'; -import type { JWK, JSONWebKeySet, JWSHeaderParameters, FlattenedJWSInput, CryptoKey } from 'jose'; -import { getDb } from '../../db/connection'; -import { jwks } from '../../db/schema/auth.schema'; - -interface JwksCache { - resolver: ( - protectedHeader?: JWSHeaderParameters, - token?: FlattenedJWSInput - ) => Promise; - expiresAt: number; -} - -/** Cache TTL in milliseconds (5 minutes) */ -const CACHE_TTL_MS = 5 * 60 * 1000; - -/** Module-level cache shared across all consumers within this process */ -let cache: JwksCache | null = null; - -/** - * Load JWKS keys from the database and return as a JSONWebKeySet. - */ -async function loadJwksFromDb(databaseUrl: string): Promise { - const db = getDb(databaseUrl); - const rows = await db.select().from(jwks); - - const keys: JWK[] = []; - - for (const row of rows) { - try { - const jwk: JWK = JSON.parse(row.publicKey); - - // Ensure the kid is set (use the row ID if the JWK doesn't have one) - if (!jwk.kid) { - jwk.kid = row.id; - } - - keys.push(jwk); - } catch { - // Skip malformed keys - } - } - - return { keys }; -} - -/** - * Get or refresh the cached JWKS resolver. - */ -async function getCachedResolver( - databaseUrl: string -): Promise< - (protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput) => Promise -> { - const now = Date.now(); - - if (cache && cache.expiresAt > now) { - return cache.resolver; - } - - const jwksData = await loadJwksFromDb(databaseUrl); - - if (jwksData.keys.length === 0) { - throw new Error('No JWKS keys available in database'); - } - - const resolver = joseCreateLocalJWKSet(jwksData); - - cache = { - resolver, - expiresAt: now + CACHE_TTL_MS, - }; - - return resolver; -} - -/** - * Create a jose-compatible key getter function that reads JWKS from - * the local database with in-memory caching. - * - * This replaces createRemoteJWKSet() for the auth service itself, - * avoiding self-referential HTTP requests. - * - * @param databaseUrl - PostgreSQL connection URL - * @returns A function compatible with jose's jwtVerify second argument - */ -export function createCachedLocalJWKSet( - databaseUrl: string -): (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => Promise { - return async (protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput) => { - const resolver = await getCachedResolver(databaseUrl); - return resolver(protectedHeader, token); - }; -} - -/** - * Clear the JWKS cache. Useful for testing or when keys are rotated. - */ -export function clearJwksCache(): void { - cache = null; -} diff --git a/services/mana-core-auth/src/common/guards/optional-auth.guard.ts b/services/mana-core-auth/src/common/guards/optional-auth.guard.ts deleted file mode 100644 index 3542a5bfa..000000000 --- a/services/mana-core-auth/src/common/guards/optional-auth.guard.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import type { CanActivate, ExecutionContext } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { jwtVerify } from 'jose'; -import { createCachedLocalJWKSet } from './local-jwks-cache'; - -/** - * Optional authentication guard using locally cached JWKS (Better Auth compatible) - * - * Attaches user to request if valid token is present, but doesn't require it. - * Uses jose library with locally cached JWKS keys for EdDSA token verification. - */ -@Injectable() -export class OptionalAuthGuard implements CanActivate { - private jwks: ReturnType | null = null; - - constructor(private configService: ConfigService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromHeader(request); - - if (!token) { - // No token - allow request but no user - request.user = null; - return true; - } - - try { - // Lazy initialize local JWKS (reads from DB, cached in memory) - if (!this.jwks) { - const databaseUrl = this.configService.get('database.url') || ''; - this.jwks = createCachedLocalJWKSet(databaseUrl); - } - - // IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts) - // Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore' - const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3001'; - const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility - const audience = this.configService.get('jwt.audience') || 'manacore'; - - const { payload } = await jwtVerify(token, this.jwks, { - issuer, - audience, - }); - - // Attach user to request - request.user = { - userId: payload.sub, - email: payload.email as string, - role: payload.role as string, - }; - } catch { - // Invalid token - allow request but no user - request.user = null; - } - - return true; - } - - private extractTokenFromHeader(request: any): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} diff --git a/services/mana-core-auth/src/common/logger/index.ts b/services/mana-core-auth/src/common/logger/index.ts deleted file mode 100644 index 28b5bd37c..000000000 --- a/services/mana-core-auth/src/common/logger/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LoggerService, getLogger } from './logger.service'; -export { LoggerModule } from './logger.module'; diff --git a/services/mana-core-auth/src/common/logger/logger.module.ts b/services/mana-core-auth/src/common/logger/logger.module.ts deleted file mode 100644 index fafebc11e..000000000 --- a/services/mana-core-auth/src/common/logger/logger.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { LoggerService } from './logger.service'; - -@Global() -@Module({ - providers: [LoggerService], - exports: [LoggerService], -}) -export class LoggerModule {} diff --git a/services/mana-core-auth/src/common/logger/logger.service.ts b/services/mana-core-auth/src/common/logger/logger.service.ts deleted file mode 100644 index c49021dd5..000000000 --- a/services/mana-core-auth/src/common/logger/logger.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common'; -import * as winston from 'winston'; - -const { combine, timestamp, printf, colorize, json } = winston.format; - -// Custom format for development (readable) -const devFormat = printf(({ level, message, timestamp, context, ...meta }) => { - const ctx = context ? `[${context}]` : ''; - const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; - return `${timestamp} ${level} ${ctx} ${message}${metaStr}`; -}); - -// Create winston logger instance -function createLogger(): winston.Logger { - const isProduction = process.env.NODE_ENV === 'production'; - - return winston.createLogger({ - level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'), - format: isProduction - ? combine(timestamp(), json()) - : combine(timestamp({ format: 'HH:mm:ss' }), colorize(), devFormat), - transports: [new winston.transports.Console()], - // Don't exit on error - exitOnError: false, - }); -} - -@Injectable({ scope: Scope.TRANSIENT }) -export class LoggerService implements NestLoggerService { - private logger: winston.Logger; - private context?: string; - - constructor() { - this.logger = createLogger(); - } - - setContext(context: string): this { - this.context = context; - return this; - } - - log(message: string, ...optionalParams: unknown[]): void { - this.logger.info(message, this.formatMeta(optionalParams)); - } - - info(message: string, meta?: Record): void { - this.logger.info(message, { context: this.context, ...meta }); - } - - error(message: string, trace?: string, ...optionalParams: unknown[]): void { - this.logger.error(message, { - context: this.context, - trace, - ...this.formatMeta(optionalParams), - }); - } - - warn(message: string, ...optionalParams: unknown[]): void { - this.logger.warn(message, this.formatMeta(optionalParams)); - } - - debug(message: string, ...optionalParams: unknown[]): void { - this.logger.debug(message, this.formatMeta(optionalParams)); - } - - verbose(message: string, ...optionalParams: unknown[]): void { - this.logger.verbose(message, this.formatMeta(optionalParams)); - } - - private formatMeta(optionalParams: unknown[]): Record { - const meta: Record = { context: this.context }; - - if (optionalParams.length === 1 && typeof optionalParams[0] === 'string') { - // NestJS passes context as last param - meta.context = optionalParams[0]; - } else if (optionalParams.length > 0) { - meta.params = optionalParams; - } - - return meta; - } -} - -// Singleton instance for use outside DI (main.ts, scripts) -let globalLogger: LoggerService | null = null; - -export function getLogger(context?: string): LoggerService { - if (!globalLogger) { - globalLogger = new LoggerService(); - } - if (context) { - return new LoggerService().setContext(context); - } - return globalLogger; -} diff --git a/services/mana-core-auth/src/config/configuration.ts b/services/mana-core-auth/src/config/configuration.ts deleted file mode 100644 index 0db253839..000000000 --- a/services/mana-core-auth/src/config/configuration.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Application Configuration - * - * Loads and validates environment variables. - * Fails fast at startup if required variables are missing. - */ - -import { validateEnv, isDevelopment } from './env.validation'; - -// Validate environment on module load -const env = validateEnv(); - -export default () => ({ - port: parseInt(env.PORT, 10), - nodeEnv: env.NODE_ENV, - - database: { - // In development, allow fallback to local database - // In production, DATABASE_URL is validated as required - url: - env.DATABASE_URL || - (isDevelopment() ? 'postgresql://manacore:manacore@localhost:5432/manacore_auth' : ''), - }, - - jwt: { - // Better Auth uses JWKS from database, these are legacy/fallback - publicKey: (env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'), - privateKey: (env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'), - accessTokenExpiry: env.JWT_ACCESS_TOKEN_EXPIRY, - refreshTokenExpiry: env.JWT_REFRESH_TOKEN_EXPIRY, - issuer: env.JWT_ISSUER, - audience: env.JWT_AUDIENCE, - }, - - redis: { - host: env.REDIS_HOST || 'localhost', - port: parseInt(env.REDIS_PORT || '6379', 10), - password: env.REDIS_PASSWORD, - }, - - stripe: { - secretKey: env.STRIPE_SECRET_KEY || '', - webhookSecret: env.STRIPE_WEBHOOK_SECRET || '', - publishableKey: env.STRIPE_PUBLISHABLE_KEY || '', - }, - - cors: { - origin: - env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || - (isDevelopment() - ? [ - 'http://localhost:3000', - 'http://localhost:5173', - 'http://localhost:5174', - 'http://localhost:8081', - ] - : []), - credentials: true, - }, - - rateLimit: { - ttl: parseInt(env.RATE_LIMIT_TTL || '60', 10), - limit: parseInt(env.RATE_LIMIT_MAX || '100', 10), - }, - - // Credits config removed - no free credits system - - ai: { - geminiApiKey: env.GOOGLE_GENAI_API_KEY || '', - }, - - storage: { - publicUrl: env.MANACORE_STORAGE_PUBLIC_URL || '', - }, - - baseUrl: env.BASE_URL || (isDevelopment() ? 'http://localhost:3001' : ''), -}); diff --git a/services/mana-core-auth/src/config/env.validation.ts b/services/mana-core-auth/src/config/env.validation.ts deleted file mode 100644 index 3223070ef..000000000 --- a/services/mana-core-auth/src/config/env.validation.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Environment Variable Validation - * - * Validates all required environment variables at startup. - * Fails fast with clear error messages if configuration is invalid. - */ - -// Load .env file before validation runs -import * as dotenv from 'dotenv'; -dotenv.config(); - -import { z } from 'zod'; - -// Schema for environment variables -const envSchema = z.object({ - // Node environment - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - PORT: z.string().regex(/^\d+$/).default('3001'), - - // Database - REQUIRED in production - DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), - - // Redis - optional in development, recommended in production - REDIS_HOST: z.string().optional(), - REDIS_PORT: z.string().regex(/^\d+$/).optional(), - REDIS_PASSWORD: z.string().optional(), - - // JWT - Better Auth uses JWKS, so these are optional legacy config - JWT_PUBLIC_KEY: z.string().optional(), - JWT_PRIVATE_KEY: z.string().optional(), - JWT_ISSUER: z.string().default('manacore'), - JWT_AUDIENCE: z.string().default('manacore'), - JWT_ACCESS_TOKEN_EXPIRY: z.string().default('15m'), - JWT_REFRESH_TOKEN_EXPIRY: z.string().default('7d'), - - // CORS - REQUIRED in production - CORS_ORIGINS: z.string().optional(), - - // Stripe - optional, but credit system won't work without it - STRIPE_SECRET_KEY: z.string().optional(), - STRIPE_WEBHOOK_SECRET: z.string().optional(), - STRIPE_PUBLISHABLE_KEY: z.string().optional(), - - // SMTP - optional, emails will be logged if not configured - SMTP_HOST: z.string().optional(), - SMTP_PORT: z.string().optional(), - SMTP_USER: z.string().optional(), - SMTP_PASSWORD: z.string().optional(), - SMTP_FROM: z.string().optional(), - - // Rate limiting - RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(), - RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(), - - // AI - GOOGLE_GENAI_API_KEY: z.string().optional(), - - // Storage - MANACORE_STORAGE_PUBLIC_URL: z.string().optional(), - - // Base URL for callbacks - BASE_URL: z.string().url().optional(), - - // Log level - LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(), -}); - -// Production-specific schema with stricter requirements -const productionEnvSchema = envSchema.extend({ - // In production, these are mandatory - CORS_ORIGINS: z.string().min(1, 'CORS_ORIGINS is required in production'), - BASE_URL: z.string().url('BASE_URL must be a valid URL in production'), -}); - -export type EnvConfig = z.infer; - -/** - * Validate environment variables - * - * @throws Error with detailed message if validation fails - */ -export function validateEnv(): EnvConfig { - const isProduction = process.env.NODE_ENV === 'production'; - const schema = isProduction ? productionEnvSchema : envSchema; - - const result = schema.safeParse(process.env); - - if (!result.success) { - const errors = result.error.errors - .map((err) => ` - ${err.path.join('.')}: ${err.message}`) - .join('\n'); - - const message = ` -╔══════════════════════════════════════════════════════════════╗ -║ ENVIRONMENT CONFIGURATION ERROR ║ -╚══════════════════════════════════════════════════════════════╝ - -The following environment variables are missing or invalid: - -${errors} - -${isProduction ? 'Production mode requires stricter configuration.' : ''} - -Please check your .env file or environment variables. -For development, copy .env.example to .env and fill in the values. -`; - - console.error(message); - throw new Error(`Environment validation failed: ${result.error.message}`); - } - - // Additional production warnings (non-fatal) - if (isProduction) { - const warnings: string[] = []; - - if (!result.data.STRIPE_SECRET_KEY) { - warnings.push('STRIPE_SECRET_KEY not set - credit system will not work'); - } - if (!result.data.SMTP_HOST) { - warnings.push('SMTP not configured - emails will only be logged'); - } - if (!result.data.REDIS_HOST) { - warnings.push('REDIS_HOST not set - using in-memory session storage (not recommended)'); - } - - if (warnings.length > 0) { - console.warn('\n⚠️ Production Warnings:'); - warnings.forEach((w) => console.warn(` - ${w}`)); - console.warn(''); - } - } - - return result.data; -} - -/** - * Check if running in development mode - */ -export function isDevelopment(): boolean { - return process.env.NODE_ENV !== 'production'; -} - -/** - * Check if running in production mode - */ -export function isProduction(): boolean { - return process.env.NODE_ENV === 'production'; -} diff --git a/services/mana-core-auth/src/db/connection.ts b/services/mana-core-auth/src/db/connection.ts deleted file mode 100644 index e84b0fa08..000000000 --- a/services/mana-core-auth/src/db/connection.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './schema'; - -let connection: ReturnType | null = null; -let db: ReturnType | null = null; - -export function getConnection(databaseUrl: string) { - if (!connection) { - connection = postgres(databaseUrl, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, - }); - } - return connection; -} - -export function getDb(databaseUrl: string) { - if (!db) { - const conn = getConnection(databaseUrl); - db = drizzle(conn, { schema }); - } - return db; -} - -export async function closeConnection() { - if (connection) { - await connection.end(); - connection = null; - db = null; - } -} diff --git a/services/mana-core-auth/src/db/migrate.ts b/services/mana-core-auth/src/db/migrate.ts deleted file mode 100644 index 3bff51152..000000000 --- a/services/mana-core-auth/src/db/migrate.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Database Migration Script with Advisory Locks - * - * This script safely runs database migrations with the following features: - * - Advisory locks to prevent concurrent migrations - * - Retry logic for transient network failures - * - Timeout protection - * - Proper cleanup on exit - * - Graceful handling when no migrations exist - * - * Usage: - * pnpm db:migrate # Run migrations - * MIGRATION_TIMEOUT=600 pnpm db:migrate # With custom timeout (seconds) - */ - -import { drizzle } from 'drizzle-orm/postgres-js'; -import { migrate } from 'drizzle-orm/postgres-js/migrator'; -import { sql } from 'drizzle-orm'; -import postgres from 'postgres'; -import * as dotenv from 'dotenv'; -import * as fs from 'fs'; -import * as path from 'path'; - -// Load environment variables -dotenv.config(); - -// Configuration -const MIGRATION_LOCK_ID = 987654321; // Unique lock ID for mana-core-auth migrations -const MAX_LOCK_WAIT_MS = parseInt(process.env.MIGRATION_TIMEOUT || '300', 10) * 1000; // Default 5 minutes -const MAX_RETRIES = 3; -const RETRY_DELAY_MS = 2000; - -/** - * Retry wrapper for transient errors - */ -async function withRetry( - operation: () => Promise, - operationName: string, - maxRetries = MAX_RETRIES -): Promise { - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error as Error; - - // Check if error is transient (network-related) - const isTransient = - lastError.message?.includes('ECONNREFUSED') || - lastError.message?.includes('ETIMEDOUT') || - lastError.message?.includes('ENOTFOUND') || - lastError.message?.includes('connection') || - (lastError as any).code === '57P03'; // PostgreSQL: cannot connect now - - if (!isTransient || attempt === maxRetries) { - throw error; - } - - const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff - console.log( - `\u26a0\ufe0f [${operationName}] Transient error, retrying in ${delay}ms... (attempt ${attempt}/${maxRetries})` - ); - console.log(` Error: ${lastError.message}`); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - - throw lastError!; -} - -/** - * Acquire PostgreSQL advisory lock - */ -async function acquireLock(db: ReturnType): Promise { - const result = await db.execute( - sql`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID}) as acquired` - ); - return (result as any)[0]?.acquired === true; -} - -/** - * Release PostgreSQL advisory lock - */ -async function releaseLock(db: ReturnType): Promise { - await db.execute(sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`); -} - -/** - * Wait for migration lock with timeout - */ -async function waitForLock(db: ReturnType): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < MAX_LOCK_WAIT_MS) { - const acquired = await acquireLock(db); - if (acquired) { - return true; - } - - const elapsed = Math.round((Date.now() - startTime) / 1000); - console.log(`\u23f3 Waiting for migration lock... (${elapsed}s / ${MAX_LOCK_WAIT_MS / 1000}s)`); - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - - return false; -} - -/** - * Main migration function - */ -async function runMigrations(): Promise { - const databaseUrl = process.env.DATABASE_URL; - - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - - console.log('\n\ud83d\udd04 Starting database migration process...'); - console.log(` Lock ID: ${MIGRATION_LOCK_ID}`); - console.log(` Timeout: ${MAX_LOCK_WAIT_MS / 1000}s`); - console.log(''); - - // Create connection with single connection for migrations - const connection = postgres(databaseUrl, { - max: 1, - idle_timeout: 20, - connect_timeout: 30, - }); - - const db = drizzle(connection); - let lockAcquired = false; - - try { - // Test database connection - console.log('\ud83d\udd0c Testing database connection...'); - await withRetry(async () => { - await db.execute(sql`SELECT 1`); - }, 'Database connection'); - console.log('\u2705 Database connection successful\n'); - - // Attempt to acquire advisory lock - console.log('\ud83d\udd12 Attempting to acquire migration lock...'); - - lockAcquired = await withRetry(() => acquireLock(db), 'Acquire lock'); - - if (!lockAcquired) { - console.log('\u23f3 Another instance is running migrations. Waiting for lock...'); - - lockAcquired = await waitForLock(db); - - if (!lockAcquired) { - throw new Error( - `Migration lock timeout after ${MAX_LOCK_WAIT_MS / 1000}s - another migration may be stuck` - ); - } - } - - console.log('\u2705 Migration lock acquired\n'); - - // Check if migration files exist - const migrationsFolder = './src/db/migrations'; - const journalPath = path.join(migrationsFolder, 'meta', '_journal.json'); - - if (!fs.existsSync(journalPath)) { - console.log('\u26a0\ufe0f No migration files found (meta/_journal.json missing)'); - console.log(' This is normal if you have not generated any migrations yet.'); - console.log(' To generate migrations, run: pnpm db:generate'); - console.log(' For development, you can use: pnpm db:push'); - console.log('\n\u2705 No migrations to run\n'); - return; - } - - // Run migrations - console.log('\ud83d\udce6 Running database migrations...'); - - await withRetry( - async () => { - await migrate(db, { - migrationsFolder, - }); - }, - 'Run migrations', - 1 // Only 1 attempt for actual migrations (they should be idempotent) - ); - - console.log('\u2705 Migrations completed successfully\n'); - } catch (error) { - console.error('\n\u274c Migration failed:', error); - throw error; - } finally { - // Always attempt to release lock - if (lockAcquired) { - try { - await releaseLock(db); - console.log('\ud83d\udd13 Migration lock released'); - } catch (unlockError) { - console.error('\u26a0\ufe0f Failed to release lock:', unlockError); - } - } - - // Close connection - try { - await connection.end(); - console.log('\ud83d\udd0c Database connection closed\n'); - } catch (closeError) { - console.error('\u26a0\ufe0f Failed to close connection:', closeError); - } - } -} - -// Run migrations -runMigrations() - .then(() => { - console.log('\ud83c\udf89 Migration process completed successfully'); - process.exit(0); - }) - .catch((error) => { - console.error('\n\ud83d\udca5 Migration process failed:', error.message); - process.exit(1); - }); diff --git a/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql b/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql deleted file mode 100644 index be3c197e2..000000000 --- a/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql +++ /dev/null @@ -1,40 +0,0 @@ --- Migration: Simplify Credits System --- Date: 2026-02-16 --- Description: Remove free credits and B2B organization credits --- --- This migration: --- 1. Migrates existing free credits to balance (one-time) --- 2. Drops B2B organization credit tables --- 3. Removes free credit columns from balances table --- --- IMPORTANT: Run this migration during low-traffic period as it modifies the balances table - --- Step 1: Migrate free credits to balance (one-time conversion) --- Any existing free_credits_remaining are added to the main balance -UPDATE credits.balances -SET balance = balance + free_credits_remaining -WHERE free_credits_remaining > 0; - --- Step 2: Drop B2B organization credit tables (if they exist) -DROP TABLE IF EXISTS credits.credit_allocations CASCADE; -DROP TABLE IF EXISTS credits.organization_balances CASCADE; - --- Step 3: Remove organization_id from transactions (if column exists) -ALTER TABLE credits.transactions -DROP COLUMN IF EXISTS organization_id; - --- Step 4: Remove free credit columns from balances table -ALTER TABLE credits.balances -DROP COLUMN IF EXISTS free_credits_remaining, -DROP COLUMN IF EXISTS daily_free_credits, -DROP COLUMN IF EXISTS last_daily_reset_at; - --- Step 5: Drop old transaction type values (Note: PostgreSQL doesn't support direct enum value removal) --- The old values (bonus, expiry, adjustment, gift_reserve, gift_release, gift_receive) --- remain in the enum for backward compatibility with historical data. --- New transactions will only use: purchase, usage, refund, gift - --- Verification queries (run manually to confirm migration): --- SELECT COUNT(*) FROM credits.balances WHERE free_credits_remaining IS NOT NULL; -- Should be 0 after migration --- SELECT column_name FROM information_schema.columns WHERE table_schema = 'credits' AND table_name = 'balances'; --- SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'credits' AND table_name = 'organization_balances'; -- Should be 0 diff --git a/services/mana-core-auth/src/db/schema/api-keys.schema.ts b/services/mana-core-auth/src/db/schema/api-keys.schema.ts deleted file mode 100644 index 31491adea..000000000 --- a/services/mana-core-auth/src/db/schema/api-keys.schema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { text, timestamp, jsonb, integer, index } from 'drizzle-orm/pg-core'; -import { authSchema, users } from './auth.schema'; - -/** - * API Keys table for programmatic access to services. - * Keys are hashed using SHA-256 for security - the full key is only shown once at creation. - */ -export const apiKeys = authSchema.table( - 'api_keys', - { - id: text('id').primaryKey(), // nanoid - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - name: text('name').notNull(), // User-friendly name for the key - keyPrefix: text('key_prefix').notNull(), // "sk_live_abc..." for display (first 12 chars) - keyHash: text('key_hash').notNull(), // SHA-256 hash of the full key - scopes: jsonb('scopes').$type().default(['stt', 'tts']).notNull(), // Allowed service scopes - rateLimitRequests: integer('rate_limit_requests').default(60).notNull(), // Requests per window - rateLimitWindow: integer('rate_limit_window').default(60).notNull(), // Window in seconds - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - lastUsedAt: timestamp('last_used_at', { withTimezone: true }), - revokedAt: timestamp('revoked_at', { withTimezone: true }), - }, - (table) => [ - index('api_keys_user_id_idx').on(table.userId), - index('api_keys_key_hash_idx').on(table.keyHash), - ] -); - -export type ApiKey = typeof apiKeys.$inferSelect; -export type NewApiKey = typeof apiKeys.$inferInsert; diff --git a/services/mana-core-auth/src/db/schema/auth.schema.ts b/services/mana-core-auth/src/db/schema/auth.schema.ts deleted file mode 100644 index 355f86f71..000000000 --- a/services/mana-core-auth/src/db/schema/auth.schema.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { - pgSchema, - uuid, - text, - timestamp, - boolean, - jsonb, - pgEnum, - index, - integer, -} from 'drizzle-orm/pg-core'; - -export const authSchema = pgSchema('auth'); - -// Enum for user roles -export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']); - -// Users table (Better Auth schema) -export const users = authSchema.table('users', { - id: text('id').primaryKey(), // Better Auth generates nanoid - name: text('name').notNull(), - email: text('email').unique().notNull(), - emailVerified: boolean('email_verified').default(false).notNull(), - image: text('image'), // Better Auth uses 'image' not 'avatarUrl' - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - // Custom fields (not required by Better Auth) - role: userRoleEnum('role').default('user').notNull(), - twoFactorEnabled: boolean('two_factor_enabled').default(false), - deletedAt: timestamp('deleted_at', { withTimezone: true }), -}); - -// Sessions table (Better Auth schema) -export const sessions = authSchema.table('sessions', { - id: text('id').primaryKey(), // Better Auth generates nanoid - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - token: text('token').unique().notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - // Custom fields (not required by Better Auth) - refreshToken: text('refresh_token').unique(), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), - deviceId: text('device_id'), - deviceName: text('device_name'), - lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(), - revokedAt: timestamp('revoked_at', { withTimezone: true }), - rememberMe: boolean('remember_me').default(false), -}); - -// Accounts table (for OAuth providers and credentials - Better Auth schema) -export const accounts = authSchema.table('accounts', { - id: text('id').primaryKey(), // Better Auth generates nanoid - accountId: text('account_id').notNull(), // Better Auth field - providerId: text('provider_id').notNull(), // Better Auth field (was 'provider') - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - idToken: text('id_token'), - accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), - scope: text('scope'), - password: text('password'), // Better Auth stores hashed password here for credential provider - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// Verification table (Better Auth schema - for email verification, password reset) -export const verificationTokens = authSchema.table( - 'verification', - { - id: text('id').primaryKey(), // Better Auth generates nanoid - identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email) - value: text('value').notNull(), // Better Auth uses value (the token) - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - identifierIdx: index('verification_identifier_idx').on(table.identifier), - }) -); - -// Password table (separate for security) -export const passwords = authSchema.table('passwords', { - userId: text('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - hashedPassword: text('hashed_password').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// Two-factor authentication -export const twoFactorAuth = authSchema.table('two_factor_auth', { - userId: text('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - secret: text('secret').notNull(), - enabled: boolean('enabled').default(false).notNull(), - backupCodes: text('backup_codes').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - enabledAt: timestamp('enabled_at', { withTimezone: true }), -}); - -// Security events log -export const securityEvents = authSchema.table('security_events', { - id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID - userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), - eventType: text('event_type').notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - metadata: jsonb('metadata'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// JWKS table (Better Auth JWT plugin - stores signing keys) -export const jwks = authSchema.table('jwks', { - id: text('id').primaryKey(), - publicKey: text('public_key').notNull(), - privateKey: text('private_key').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OIDC Provider tables (Better Auth OIDC Provider plugin) -// OAuth Applications (OIDC Clients like Matrix/Synapse) -export const oauthApplications = authSchema.table('oauth_applications', { - id: text('id').primaryKey(), - name: text('name').notNull(), - icon: text('icon'), - metadata: text('metadata'), - clientId: text('client_id').unique().notNull(), - clientSecret: text('client_secret').notNull(), - redirectUrls: text('redirect_urls').notNull(), // Comma-separated URLs (Better Auth expects 'redirectUrls' property name) - type: text('type').notNull().default('web'), // web, native, spa - disabled: boolean('disabled').default(false).notNull(), - userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Access Tokens -export const oauthAccessTokens = authSchema.table('oauth_access_tokens', { - id: text('id').primaryKey(), - accessToken: text('access_token').unique().notNull(), - refreshToken: text('refresh_token').unique(), - accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }).notNull(), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), - clientId: text('client_id').notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - scopes: text('scopes').notNull(), // JSON array as text - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Authorization Codes -export const oauthAuthorizationCodes = authSchema.table('oauth_authorization_codes', { - id: text('id').primaryKey(), - code: text('code').unique().notNull(), - clientId: text('client_id').notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - scopes: text('scopes').notNull(), // JSON array as text - redirectUri: text('redirect_uri').notNull(), - codeChallenge: text('code_challenge'), - codeChallengeMethod: text('code_challenge_method'), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// OAuth Consents (user consent records for OIDC scopes) -export const oauthConsents = authSchema.table('oauth_consents', { - id: text('id').primaryKey(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - clientId: text('client_id').notNull(), - scopes: text('scopes').notNull(), // JSON array as text - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// Matrix User Links table (for Bot SSO) -// Links Matrix user IDs to Mana user accounts for automatic bot authentication -export const matrixUserLinks = authSchema.table( - 'matrix_user_links', - { - id: text('id').primaryKey(), // nanoid - matrixUserId: text('matrix_user_id').unique().notNull(), // e.g., @user:matrix.mana.how - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(), - lastUsedAt: timestamp('last_used_at', { withTimezone: true }), - // Optional: store email for convenience (denormalized from users table) - email: text('email'), - }, - (table) => ({ - userIdIdx: index('matrix_user_links_user_id_idx').on(table.userId), - }) -); - -// Passkeys table (WebAuthn credentials) -export const passkeys = authSchema.table( - 'passkeys', - { - id: text('id').primaryKey(), // nanoid - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - credentialId: text('credential_id').unique().notNull(), // base64url-encoded - publicKey: text('public_key').notNull(), // base64url-encoded COSE public key - counter: integer('counter').default(0).notNull(), // signature counter - deviceType: text('device_type').notNull(), // 'singleDevice' | 'multiDevice' - backedUp: boolean('backed_up').default(false).notNull(), - transports: jsonb('transports').$type(), // ['internal', 'hybrid', etc.] - friendlyName: text('friendly_name'), - lastUsedAt: timestamp('last_used_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('passkeys_user_id_idx').on(table.userId), - }) -); - -// User settings table (synced across all apps) -export const userSettings = authSchema.table('user_settings', { - userId: text('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - - // Global defaults (applies to all apps) - // { nav: { desktopPosition, sidebarCollapsed }, theme: { mode, colorScheme }, locale } - globalSettings: jsonb('global_settings') - .default({ - nav: { desktopPosition: 'top', sidebarCollapsed: false }, - theme: { mode: 'system', colorScheme: 'ocean' }, - locale: 'de', - }) - .notNull(), - - // Per-app overrides (applies to all devices) - // { "calendar": { nav: {...}, theme: {...} }, "chat": {...} } - appOverrides: jsonb('app_overrides').default({}).notNull(), - - // Per-device settings (device-specific app settings) - // { "device-abc-123": { deviceName: "MacBook", deviceType: "desktop", lastSeen: "...", apps: { "calendar": { dayStartHour: 6, ... } } } } - deviceSettings: jsonb('device_settings').default({}).notNull(), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); diff --git a/services/mana-core-auth/src/db/schema/feedback.schema.ts b/services/mana-core-auth/src/db/schema/feedback.schema.ts deleted file mode 100644 index 7987e37ca..000000000 --- a/services/mana-core-auth/src/db/schema/feedback.schema.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - pgSchema, - uuid, - text, - timestamp, - boolean, - jsonb, - integer, - index, - pgEnum, - uniqueIndex, -} from 'drizzle-orm/pg-core'; -import { users } from './auth.schema'; - -export const feedbackSchema = pgSchema('feedback'); - -// Category enum -export const feedbackCategoryEnum = pgEnum('feedback_category', [ - 'bug', - 'feature', - 'improvement', - 'question', - 'other', -]); - -// Status enum -export const feedbackStatusEnum = pgEnum('feedback_status', [ - 'submitted', - 'under_review', - 'planned', - 'in_progress', - 'completed', - 'declined', -]); - -// User feedback table -export const userFeedback = feedbackSchema.table( - 'user_feedback', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - appId: text('app_id').notNull(), // 'chat', 'picture', 'zitare', etc. - - // Content - title: text('title'), - feedbackText: text('feedback_text').notNull(), - category: feedbackCategoryEnum('category').default('feature').notNull(), - - // Status & Publishing - status: feedbackStatusEnum('status').default('submitted').notNull(), - isPublic: boolean('is_public').default(false).notNull(), - adminResponse: text('admin_response'), - - // Voting (denormalized for performance) - voteCount: integer('vote_count').default(0).notNull(), - - // Metadata - deviceInfo: jsonb('device_info'), - - // Timestamps - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - publishedAt: timestamp('published_at', { withTimezone: true }), - completedAt: timestamp('completed_at', { withTimezone: true }), - }, - (table) => ({ - userIdx: index('feedback_user_idx').on(table.userId), - appIdx: index('feedback_app_idx').on(table.appId), - publicIdx: index('feedback_public_idx').on(table.isPublic), - statusIdx: index('feedback_status_idx').on(table.status), - createdAtIdx: index('feedback_created_at_idx').on(table.createdAt), - }) -); - -// Feedback votes table -export const feedbackVotes = feedbackSchema.table( - 'feedback_votes', - { - id: uuid('id').primaryKey().defaultRandom(), - feedbackId: uuid('feedback_id') - .references(() => userFeedback.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - uniqueVote: uniqueIndex('feedback_vote_unique').on(table.feedbackId, table.userId), - feedbackIdx: index('feedback_votes_feedback_idx').on(table.feedbackId), - }) -); diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts deleted file mode 100644 index fa9a6cf3c..000000000 --- a/services/mana-core-auth/src/db/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './api-keys.schema'; -export * from './auth.schema'; -export * from './login-attempts.schema'; -export * from './organizations.schema'; diff --git a/services/mana-core-auth/src/db/schema/login-attempts.schema.ts b/services/mana-core-auth/src/db/schema/login-attempts.schema.ts deleted file mode 100644 index 63811089a..000000000 --- a/services/mana-core-auth/src/db/schema/login-attempts.schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Login Attempts Schema - * - * Tracks login attempts for account lockout functionality. - * Failed attempts within a time window trigger account lockout. - */ - -import { pgSchema, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core'; - -const authSchema = pgSchema('auth'); - -export const loginAttempts = authSchema.table( - 'login_attempts', - { - id: serial('id').primaryKey(), - email: text('email').notNull(), - ipAddress: text('ip_address'), - successful: boolean('successful').default(false).notNull(), - attemptedAt: timestamp('attempted_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => [index('login_attempts_email_attempted_at_idx').on(table.email, table.attemptedAt)] -); diff --git a/services/mana-core-auth/src/db/schema/organizations.schema.ts b/services/mana-core-auth/src/db/schema/organizations.schema.ts deleted file mode 100644 index 90e38d881..000000000 --- a/services/mana-core-auth/src/db/schema/organizations.schema.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; -import { authSchema, users } from './auth.schema'; - -/** - * Better Auth Organization Tables - * These tables follow Better Auth's organization plugin schema requirements - * @see https://www.better-auth.com/docs/plugins/organization - * - * Note: Better Auth uses TEXT for IDs (nanoid/ULID), but we use UUID for users. - * The foreign key constraints will be added via raw SQL migration to handle the type difference. - */ - -// Organizations table -export const organizations = authSchema.table( - 'organizations', - { - id: text('id').primaryKey(), // Better Auth uses TEXT IDs (ULIDs/nanoids) - name: text('name').notNull(), - slug: text('slug').unique(), - logo: text('logo'), - metadata: jsonb('metadata'), // Additional organization data - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - slugIdx: index('organizations_slug_idx').on(table.slug), - }) -); - -// Members table (links users to organizations with roles) -export const members = authSchema.table( - 'members', - { - id: text('id').primaryKey(), // Better Auth uses TEXT IDs - organizationId: text('organization_id') - .references(() => organizations.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id').notNull(), // References auth.users.id (UUID cast to TEXT) - role: text('role').notNull(), // 'owner', 'admin', 'member', or custom roles - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - organizationIdIdx: index('members_organization_id_idx').on(table.organizationId), - userIdIdx: index('members_user_id_idx').on(table.userId), - organizationUserIdx: index('members_organization_user_idx').on( - table.organizationId, - table.userId - ), - }) -); - -// Invitations table (for inviting users to organizations) -export const invitations = authSchema.table( - 'invitations', - { - id: text('id').primaryKey(), // Better Auth uses TEXT IDs - organizationId: text('organization_id') - .references(() => organizations.id, { onDelete: 'cascade' }) - .notNull(), - email: text('email').notNull(), - role: text('role').notNull(), // Role they'll have when they accept - status: text('status').notNull(), // 'pending', 'accepted', 'rejected', 'canceled' - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), - inviterId: text('inviter_id'), // References auth.users.id (UUID cast to TEXT) - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - organizationIdIdx: index('invitations_organization_id_idx').on(table.organizationId), - emailIdx: index('invitations_email_idx').on(table.email), - statusIdx: index('invitations_status_idx').on(table.status), - }) -); diff --git a/services/mana-core-auth/src/db/schema/subscriptions.schema.ts b/services/mana-core-auth/src/db/schema/subscriptions.schema.ts deleted file mode 100644 index 0016550b1..000000000 --- a/services/mana-core-auth/src/db/schema/subscriptions.schema.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - pgSchema, - uuid, - text, - timestamp, - integer, - boolean, - jsonb, - index, - pgEnum, -} from 'drizzle-orm/pg-core'; -import { users } from './auth.schema'; - -export const subscriptionsSchema = pgSchema('subscriptions'); - -// Subscription status enum -export const subscriptionStatusEnum = pgEnum('subscription_status', [ - 'active', - 'canceled', - 'past_due', - 'unpaid', - 'trialing', - 'incomplete', - 'incomplete_expired', - 'paused', -]); - -// Billing interval enum -export const billingIntervalEnum = pgEnum('billing_interval', ['month', 'year']); - -// Subscription plans (Free, Pro, Enterprise etc.) -export const plans = subscriptionsSchema.table('plans', { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), // Free, Pro, Enterprise - description: text('description'), - // Monthly credits included - monthlyCredits: integer('monthly_credits').notNull().default(0), - // Pricing - priceMonthlyEuroCents: integer('price_monthly_euro_cents').notNull().default(0), - priceYearlyEuroCents: integer('price_yearly_euro_cents').notNull().default(0), - // Stripe Price IDs - stripePriceIdMonthly: text('stripe_price_id_monthly'), - stripePriceIdYearly: text('stripe_price_id_yearly'), - stripeProductId: text('stripe_product_id'), - // Features (JSON array of feature strings) - features: jsonb('features').$type().default([]), - // Limits - maxTeamMembers: integer('max_team_members'), - maxOrganizations: integer('max_organizations'), - // Meta - isDefault: boolean('is_default').default(false).notNull(), - isEnterprise: boolean('is_enterprise').default(false).notNull(), - active: boolean('active').default(true).notNull(), - sortOrder: integer('sort_order').default(0).notNull(), - metadata: jsonb('metadata'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// User subscriptions -export const subscriptions = subscriptionsSchema.table( - 'subscriptions', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - planId: uuid('plan_id') - .references(() => plans.id) - .notNull(), - // Stripe references - stripeSubscriptionId: text('stripe_subscription_id').unique(), - stripeCustomerId: text('stripe_customer_id'), - stripePriceId: text('stripe_price_id'), - // Status - status: subscriptionStatusEnum('status').default('active').notNull(), - billingInterval: billingIntervalEnum('billing_interval').default('month').notNull(), - // Dates - currentPeriodStart: timestamp('current_period_start', { withTimezone: true }), - currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }), - cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(), - canceledAt: timestamp('canceled_at', { withTimezone: true }), - endedAt: timestamp('ended_at', { withTimezone: true }), - trialStart: timestamp('trial_start', { withTimezone: true }), - trialEnd: timestamp('trial_end', { withTimezone: true }), - // Meta - metadata: jsonb('metadata'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('subscriptions_user_id_idx').on(table.userId), - stripeSubscriptionIdIdx: index('subscriptions_stripe_subscription_id_idx').on( - table.stripeSubscriptionId - ), - statusIdx: index('subscriptions_status_idx').on(table.status), - }) -); - -// Invoices (synced from Stripe) -export const invoices = subscriptionsSchema.table( - 'invoices', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - subscriptionId: uuid('subscription_id').references(() => subscriptions.id), - // Stripe references - stripeInvoiceId: text('stripe_invoice_id').unique().notNull(), - stripeCustomerId: text('stripe_customer_id'), - // Invoice details - number: text('number'), - status: text('status').notNull(), // draft, open, paid, void, uncollectible - amountDueEuroCents: integer('amount_due_euro_cents').notNull(), - amountPaidEuroCents: integer('amount_paid_euro_cents').notNull().default(0), - currency: text('currency').default('eur').notNull(), - // URLs - hostedInvoiceUrl: text('hosted_invoice_url'), - invoicePdfUrl: text('invoice_pdf_url'), - // Dates - periodStart: timestamp('period_start', { withTimezone: true }), - periodEnd: timestamp('period_end', { withTimezone: true }), - dueDate: timestamp('due_date', { withTimezone: true }), - paidAt: timestamp('paid_at', { withTimezone: true }), - // Meta - metadata: jsonb('metadata'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('invoices_user_id_idx').on(table.userId), - stripeInvoiceIdIdx: index('invoices_stripe_invoice_id_idx').on(table.stripeInvoiceId), - statusIdx: index('invoices_status_idx').on(table.status), - }) -); diff --git a/services/mana-core-auth/src/db/seed-dev-user.ts b/services/mana-core-auth/src/db/seed-dev-user.ts deleted file mode 100644 index 5062e11bd..000000000 --- a/services/mana-core-auth/src/db/seed-dev-user.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Seed Dev User Script - * - * Creates a development user for easy local testing. - * Run with: pnpm db:seed:dev - * - * IMPORTANT: The auth server must be running for this script to work! - * Start it first with: pnpm start:dev - * - * Credentials: - * Email: dev@manacore.local - * Password: devpassword123 - */ - -import { config } from 'dotenv'; -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import { users } from './schema/auth.schema'; -import { eq } from 'drizzle-orm'; - -// Load environment variables -config(); - -const DEV_USERS = [ - { - email: 'dev@manacore.local', - password: 'devpassword123', - name: 'Dev User', - }, - { - email: 't@t.de', - password: 'test1234', - name: 'Test User', - }, -]; - -const AUTH_URL = process.env.BASE_URL || 'http://localhost:3001'; - -async function seedDevUser() { - const databaseUrl = - process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore'; - - console.log('🌱 Seeding dev users...'); - console.log(''); - - const connection = postgres(databaseUrl, { max: 1 }); - const db = drizzle(connection); - - try { - for (const devUser of DEV_USERS) { - console.log(`Processing: ${devUser.email}`); - - // Check if user already exists - const existingUsers = await db.select().from(users).where(eq(users.email, devUser.email)); - - if (existingUsers.length > 0) { - // User exists - just make sure email is verified - await db.update(users).set({ emailVerified: true }).where(eq(users.email, devUser.email)); - console.log(` ✅ User already exists, email verification ensured.`); - continue; - } - - // Register user via HTTP API (auth server must be running) - console.log(` 📡 Registering via API at ${AUTH_URL}...`); - - const response = await fetch(`${AUTH_URL}/api/v1/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: devUser.email, - password: devUser.password, - name: devUser.name, - }), - }); - - if (!response.ok) { - const error = await response.text(); - console.error(` ❌ Registration failed: ${response.status} - ${error}`); - continue; - } - - const result = await response.json(); - console.log(` User created with ID: ${result.user?.id || 'unknown'}`); - - // Set emailVerified to true (skip email verification for dev user) - await db.update(users).set({ emailVerified: true }).where(eq(users.email, devUser.email)); - console.log(` ✅ User created and email verified!`); - } - - console.log(''); - console.log('Dev users ready:'); - for (const devUser of DEV_USERS) { - console.log(` Email: ${devUser.email}`); - console.log(` Password: ${devUser.password}`); - console.log(''); - } - } catch (error) { - if (error instanceof Error && error.message.includes('fetch')) { - console.error(''); - console.error('❌ Could not connect to auth server!'); - console.error(' Make sure the auth server is running:'); - console.error(' pnpm dev:auth'); - console.error(''); - } else { - console.error('❌ Error seeding dev users:', error); - } - throw error; - } finally { - await connection.end(); - } -} - -// Run the seed -seedDevUser() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); diff --git a/services/mana-core-auth/src/db/seeds/seed-oidc-clients.ts b/services/mana-core-auth/src/db/seeds/seed-oidc-clients.ts deleted file mode 100644 index 33cbf15a4..000000000 --- a/services/mana-core-auth/src/db/seeds/seed-oidc-clients.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Seed Script: OIDC Clients - * - * This script creates the OIDC client entries for services that use - * Mana Core Auth as their OIDC Provider (e.g., Matrix/Synapse). - * - * Usage: - * pnpm db:seed:oidc - * - * Environment: - * DATABASE_URL - PostgreSQL connection string - * SYNAPSE_OIDC_CLIENT_SECRET - Client secret for Synapse (generate with: openssl rand -hex 32) - */ - -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import { oauthApplications } from '../schema/auth.schema'; -import { eq } from 'drizzle-orm'; -import { randomBytes } from 'crypto'; - -// Load environment variables -import 'dotenv/config'; - -// Generate a secure random ID -function generateId(): string { - return randomBytes(16).toString('hex'); -} - -async function seed() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) { - console.error('❌ DATABASE_URL environment variable is required'); - process.exit(1); - } - - const client = postgres(databaseUrl); - const db = drizzle(client); - - console.log('🌱 Seeding OIDC clients...\n'); - - // Get or generate Synapse client secret - const synapseClientSecret = - process.env.SYNAPSE_OIDC_CLIENT_SECRET || randomBytes(32).toString('hex'); - - if (!process.env.SYNAPSE_OIDC_CLIENT_SECRET) { - console.log('⚠️ No SYNAPSE_OIDC_CLIENT_SECRET provided, generated new secret:'); - console.log(` ${synapseClientSecret}`); - console.log(' Add this to your .env and Synapse configuration!\n'); - } - - // Check if Synapse client already exists - const existingClient = await db - .select() - .from(oauthApplications) - .where(eq(oauthApplications.clientId, 'synapse')) - .limit(1); - - if (existingClient.length > 0) { - console.log('ℹ️ Synapse OIDC client already exists, updating...'); - - await db - .update(oauthApplications) - .set({ - clientSecret: synapseClientSecret, - // Better Auth expects comma-separated string, NOT JSON array - redirectUrls: 'https://matrix.mana.how/_synapse/client/oidc/callback', - updatedAt: new Date(), - }) - .where(eq(oauthApplications.clientId, 'synapse')); - - console.log('✅ Synapse OIDC client updated\n'); - } else { - console.log('📝 Creating Synapse OIDC client...'); - - await db.insert(oauthApplications).values({ - id: generateId(), - name: 'Matrix Synapse', - icon: 'https://matrix.org/images/matrix-logo.svg', - clientId: 'synapse', - clientSecret: synapseClientSecret, - // Better Auth expects comma-separated string, NOT JSON array - redirectUrls: 'https://matrix.mana.how/_synapse/client/oidc/callback', - type: 'web', - disabled: false, - metadata: JSON.stringify({ - description: 'Matrix Synapse homeserver for DSGVO-compliant messaging', - trusted: true, - skipConsent: true, - }), - createdAt: new Date(), - updatedAt: new Date(), - }); - - console.log('✅ Synapse OIDC client created\n'); - } - - // Summary - console.log('📋 OIDC Client Summary:'); - console.log(' Client ID: synapse'); - console.log(` Client Secret: ${synapseClientSecret.substring(0, 8)}...`); - console.log(' Redirect URL: https://matrix.mana.how/_synapse/client/oidc/callback'); - console.log('\n🔐 Next steps:'); - console.log(' 1. Add SYNAPSE_OIDC_CLIENT_SECRET to Synapse environment'); - console.log(' 2. Restart Synapse to pick up OIDC configuration'); - console.log(' 3. Test SSO flow via Element Web\n'); - - await client.end(); - console.log('✨ Seeding complete!'); -} - -seed().catch((error) => { - console.error('❌ Seeding failed:', error); - process.exit(1); -}); diff --git a/services/mana-core-auth/src/db/seeds/seed-subscription-plans.ts b/services/mana-core-auth/src/db/seeds/seed-subscription-plans.ts deleted file mode 100644 index 740fc0f32..000000000 --- a/services/mana-core-auth/src/db/seeds/seed-subscription-plans.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Seed subscription plans with Stripe Price IDs - * - * This script creates/updates the default subscription plans in the database. - * Plans are idempotent - running multiple times won't create duplicates. - * - * Usage: - * pnpm db:seed:plans - * - * Prerequisites: - * 1. Stripe products and prices are already created via MCP - * 2. Set STRIPE_* environment variables with the price IDs - * - * Stripe Products (Live) - "Mana Quelle" subscriptions: - * - Mana Quelle S: prod_UDzZl1uKIHplam (4.99€/month, 47.90€/year, 500 credits) - * - Mana Quelle M: prod_UDzZXZxEVoyQMF (9.99€/month, 95.90€/year, 1000 credits) - * - Mana Quelle L: prod_UDzZcDxsDS3q1T (19.99€/month, 191.90€/year, 2000 credits) - * - Mana Quelle XL: prod_UDzZum6MMQkc0b (39.99€/month, 383.90€/year, 4000 credits) - * - Mana Quelle XXL: prod_UDzZreFcbGxdJj (99.99€/month, 959.90€/year, 10000 credits) - */ - -import 'dotenv/config'; -import postgres from 'postgres'; -import { drizzle } from 'drizzle-orm/postgres-js'; -import { eq } from 'drizzle-orm'; -import { plans } from '../schema/subscriptions.schema'; - -// Environment configuration -const DATABASE_URL = - process.env.DATABASE_URL || 'postgresql://manacore:manacore@localhost:5432/manacore_auth'; - -// Stripe Price IDs from environment (with production defaults) -const STRIPE_CONFIG = { - // Free plan (no Stripe price needed) - FREE_PRODUCT_ID: process.env.STRIPE_FREE_PRODUCT_ID || '', - - // Mana Quelle S - S_PRODUCT_ID: process.env.STRIPE_S_PRODUCT_ID || 'prod_UDzZl1uKIHplam', - S_PRICE_MONTHLY: process.env.STRIPE_S_PRICE_MONTHLY || 'price_1TFXaKAZjQCYS0ZJGQFSxm8v', - S_PRICE_YEARLY: process.env.STRIPE_S_PRICE_YEARLY || 'price_1TFXaLAZjQCYS0ZJwFnGP29S', - - // Mana Quelle M - M_PRODUCT_ID: process.env.STRIPE_M_PRODUCT_ID || 'prod_UDzZXZxEVoyQMF', - M_PRICE_MONTHLY: process.env.STRIPE_M_PRICE_MONTHLY || 'price_1TFXaMAZjQCYS0ZJMRmTgQvb', - M_PRICE_YEARLY: process.env.STRIPE_M_PRICE_YEARLY || 'price_1TFXaNAZjQCYS0ZJ6AS1vRkx', - - // Mana Quelle L - L_PRODUCT_ID: process.env.STRIPE_L_PRODUCT_ID || 'prod_UDzZcDxsDS3q1T', - L_PRICE_MONTHLY: process.env.STRIPE_L_PRICE_MONTHLY || 'price_1TFXaNAZjQCYS0ZJaqXjJ0HC', - L_PRICE_YEARLY: process.env.STRIPE_L_PRICE_YEARLY || 'price_1TFXaOAZjQCYS0ZJVndo98Pf', - - // Mana Quelle XL - XL_PRODUCT_ID: process.env.STRIPE_XL_PRODUCT_ID || 'prod_UDzZum6MMQkc0b', - XL_PRICE_MONTHLY: process.env.STRIPE_XL_PRICE_MONTHLY || 'price_1TFXaPAZjQCYS0ZJ0q7OysMg', - XL_PRICE_YEARLY: process.env.STRIPE_XL_PRICE_YEARLY || 'price_1TFXaQAZjQCYS0ZJ6rDqh2FU', - - // Mana Quelle XXL - XXL_PRODUCT_ID: process.env.STRIPE_XXL_PRODUCT_ID || 'prod_UDzZreFcbGxdJj', - XXL_PRICE_MONTHLY: process.env.STRIPE_XXL_PRICE_MONTHLY || 'price_1TFXaQAZjQCYS0ZJ3A6QB2sv', - XXL_PRICE_YEARLY: process.env.STRIPE_XXL_PRICE_YEARLY || 'price_1TFXaRAZjQCYS0ZJCuYSesCA', -}; - -// Plan definitions -const PLANS = [ - { - name: 'Free', - description: 'Kostenlos starten mit grundlegenden Features', - monthlyCredits: 50, - priceMonthlyEuroCents: 0, - priceYearlyEuroCents: 0, - stripePriceIdMonthly: null, - stripePriceIdYearly: null, - stripeProductId: STRIPE_CONFIG.FREE_PRODUCT_ID || null, - features: [ - '50 Credits pro Monat', - '3 tägliche Gratis-Credits', - 'Zugang zu allen Apps', - 'Community-Support', - ], - maxTeamMembers: null, - maxOrganizations: null, - isDefault: true, - isEnterprise: false, - sortOrder: 0, - }, - { - name: 'Mana Quelle S', - description: '500 Mana pro Monat – Basis-Zugang zu allen ManaCore Apps', - monthlyCredits: 500, - priceMonthlyEuroCents: 499, // 4.99 EUR - priceYearlyEuroCents: 4790, // 47.90 EUR (20% Rabatt) - stripePriceIdMonthly: STRIPE_CONFIG.S_PRICE_MONTHLY || null, - stripePriceIdYearly: STRIPE_CONFIG.S_PRICE_YEARLY || null, - stripeProductId: STRIPE_CONFIG.S_PRODUCT_ID || null, - features: [ - '500 Mana pro Monat', - 'Zugang zu allen Apps', - 'Chat & Bildgenerierung', - 'E-Mail Support', - ], - maxTeamMembers: 1, - maxOrganizations: 1, - isDefault: false, - isEnterprise: false, - sortOrder: 1, - }, - { - name: 'Mana Quelle M', - description: '1.000 Mana pro Monat – Erweiterter Zugang mit mehr AI-Power', - monthlyCredits: 1000, - priceMonthlyEuroCents: 999, // 9.99 EUR - priceYearlyEuroCents: 9590, // 95.90 EUR (20% Rabatt) - stripePriceIdMonthly: STRIPE_CONFIG.M_PRICE_MONTHLY || null, - stripePriceIdYearly: STRIPE_CONFIG.M_PRICE_YEARLY || null, - stripeProductId: STRIPE_CONFIG.M_PRODUCT_ID || null, - features: ['1.000 Mana pro Monat', 'Alle AI-Modelle', 'Priority-Support', 'API-Zugang'], - maxTeamMembers: 3, - maxOrganizations: 2, - isDefault: false, - isEnterprise: false, - sortOrder: 2, - }, - { - name: 'Mana Quelle L', - description: '2.000 Mana pro Monat – Premium-Zugang für Power-User', - monthlyCredits: 2000, - priceMonthlyEuroCents: 1999, // 19.99 EUR - priceYearlyEuroCents: 19190, // 191.90 EUR (20% Rabatt) - stripePriceIdMonthly: STRIPE_CONFIG.L_PRICE_MONTHLY || null, - stripePriceIdYearly: STRIPE_CONFIG.L_PRICE_YEARLY || null, - stripeProductId: STRIPE_CONFIG.L_PRODUCT_ID || null, - features: [ - '2.000 Mana pro Monat', - 'Alle Premium AI-Modelle', - 'Priority-Support', - 'Erweiterte Exportoptionen', - 'API-Zugang', - ], - maxTeamMembers: 5, - maxOrganizations: 3, - isDefault: false, - isEnterprise: false, - sortOrder: 3, - }, - { - name: 'Mana Quelle XL', - description: '4.000 Mana pro Monat – Maximale Power für Teams und Profis', - monthlyCredits: 4000, - priceMonthlyEuroCents: 3999, // 39.99 EUR - priceYearlyEuroCents: 38390, // 383.90 EUR (20% Rabatt) - stripePriceIdMonthly: STRIPE_CONFIG.XL_PRICE_MONTHLY || null, - stripePriceIdYearly: STRIPE_CONFIG.XL_PRICE_YEARLY || null, - stripeProductId: STRIPE_CONFIG.XL_PRODUCT_ID || null, - features: [ - '4.000 Mana pro Monat', - 'Alle Premium AI-Modelle', - 'Dedizierter Support', - 'Team-Features', - 'Unlimitierte API-Calls', - ], - maxTeamMembers: 10, - maxOrganizations: 5, - isDefault: false, - isEnterprise: false, - sortOrder: 4, - }, - { - name: 'Mana Quelle XXL', - description: '10.000 Mana pro Monat – Enterprise-Power ohne Limits', - monthlyCredits: 10000, - priceMonthlyEuroCents: 9999, // 99.99 EUR - priceYearlyEuroCents: 95990, // 959.90 EUR (20% Rabatt) - stripePriceIdMonthly: STRIPE_CONFIG.XXL_PRICE_MONTHLY || null, - stripePriceIdYearly: STRIPE_CONFIG.XXL_PRICE_YEARLY || null, - stripeProductId: STRIPE_CONFIG.XXL_PRODUCT_ID || null, - features: [ - '10.000 Mana pro Monat', - 'Alle Premium AI-Modelle', - 'Dedizierter Support', - 'Unlimitierte API-Calls', - 'Team-Features', - 'Custom Integrationen', - ], - maxTeamMembers: null, // Unlimited - maxOrganizations: null, // Unlimited - isDefault: false, - isEnterprise: true, - sortOrder: 5, - }, -]; - -async function seedPlans() { - console.log('Seeding subscription plans...'); - console.log(`Database: ${DATABASE_URL.replace(/:[^@]+@/, ':***@')}`); - - const client = postgres(DATABASE_URL); - const db = drizzle(client); - - try { - for (const plan of PLANS) { - // Check if plan exists - const [existing] = await db.select().from(plans).where(eq(plans.name, plan.name)).limit(1); - - if (existing) { - // Update existing plan - await db - .update(plans) - .set({ - ...plan, - updatedAt: new Date(), - }) - .where(eq(plans.id, existing.id)); - console.log(`✓ Updated plan: ${plan.name}`); - } else { - // Insert new plan - await db.insert(plans).values({ - ...plan, - } as any); - console.log(`✓ Created plan: ${plan.name}`); - } - } - - // List all plans - const allPlans = await db.select().from(plans).orderBy(plans.sortOrder); - console.log('\nAll subscription plans:'); - console.table( - allPlans.map((p) => ({ - name: p.name, - credits: p.monthlyCredits, - monthly: `€${(p.priceMonthlyEuroCents / 100).toFixed(2)}`, - yearly: `€${(p.priceYearlyEuroCents / 100).toFixed(2)}`, - stripeMonthly: p.stripePriceIdMonthly || '(not set)', - stripeYearly: p.stripePriceIdYearly || '(not set)', - default: p.isDefault, - })) - ); - - console.log('\n✅ Subscription plans seeded successfully!'); - - // Info about configured Stripe products - console.log('\n📦 Stripe Products configured:'); - console.log(' S: ', STRIPE_CONFIG.S_PRODUCT_ID || '(not set)'); - console.log(' M: ', STRIPE_CONFIG.M_PRODUCT_ID || '(not set)'); - console.log(' L: ', STRIPE_CONFIG.L_PRODUCT_ID || '(not set)'); - console.log(' XL: ', STRIPE_CONFIG.XL_PRODUCT_ID || '(not set)'); - console.log(' XXL: ', STRIPE_CONFIG.XXL_PRODUCT_ID || '(not set)'); - } catch (error) { - console.error('Error seeding plans:', error); - process.exit(1); - } finally { - await client.end(); - } -} - -// Run if called directly -seedPlans(); diff --git a/services/mana-core-auth/src/email/email.service.ts b/services/mana-core-auth/src/email/email.service.ts deleted file mode 100644 index 306b6a321..000000000 --- a/services/mana-core-auth/src/email/email.service.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Email Service - * - * Sends transactional emails via Brevo SMTP for: - * - Password reset - * - Email verification - * - Organization invitations - */ - -import * as nodemailer from 'nodemailer'; -import { getLogger } from '../common/logger'; - -const logger = getLogger('EmailService'); - -interface EmailOptions { - to: string; - subject: string; - html: string; - text?: string; -} - -// Create reusable transporter -let transporter: nodemailer.Transporter | null = null; - -function getTransporter(): nodemailer.Transporter { - if (transporter) { - return transporter; - } - - const host = process.env.SMTP_HOST || 'smtp-relay.brevo.com'; - const port = parseInt(process.env.SMTP_PORT || '587', 10); - const user = process.env.SMTP_USER; - const pass = process.env.SMTP_PASSWORD; - - if (!user || !pass) { - logger.warn('SMTP credentials not configured, emails will be logged only'); - return null as any; - } - - transporter = nodemailer.createTransport({ - host, - port, - secure: port === 465, // true for 465, false for other ports - auth: { - user, - pass, - }, - }); - - return transporter; -} - -/** - * Send an email via Brevo SMTP - */ -export async function sendEmail(options: EmailOptions): Promise { - const { to, subject, html, text } = options; - const from = process.env.SMTP_FROM || 'ManaCore '; - - logger.info('Sending email', { to, subject }); - - const transport = getTransporter(); - - if (!transport) { - logger.debug('No SMTP configured, email not sent', { to, subject }); - return false; - } - - try { - const result = await transport.sendMail({ - from, - to, - subject, - html, - text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version - }); - - logger.info('Email sent successfully', { to, messageId: result.messageId }); - return true; - } catch (error) { - logger.error('Failed to send email', error instanceof Error ? error.stack : undefined, { to }); - return false; - } -} - -/** - * Send password reset email - */ -export async function sendPasswordResetEmail( - email: string, - resetUrl: string, - userName?: string -): Promise { - const name = userName || email.split('@')[0]; - - return sendEmail({ - to: email, - subject: 'Passwort zurücksetzen - ManaCore', - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo ${name},

- -

Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. Klicke auf den Button unten, um ein neues Passwort zu erstellen:

- - - -

Dieser Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet.
- Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
- ${resetUrl} -

- - -`, - }); -} - -/** - * Send organization invitation email - */ -export async function sendInvitationEmail( - email: string, - organizationName: string, - inviterName: string, - inviteUrl: string -): Promise { - return sendEmail({ - to: email, - subject: `Einladung zu ${organizationName} - ManaCore`, - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo,

- -

${inviterName} hat dich eingeladen, der Organisation ${organizationName} auf ManaCore beizutreten.

- - - -

Diese Einladung ist 7 Tage gültig.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet. -

- - -`, - }); -} - -/** - * Send email verification email - */ -export async function sendVerificationEmail( - email: string, - verificationUrl: string, - userName?: string -): Promise { - const name = userName || email.split('@')[0]; - - return sendEmail({ - to: email, - subject: 'E-Mail bestätigen - ManaCore', - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo ${name},

- -

Willkommen bei ManaCore! Bitte bestätige deine E-Mail-Adresse, um deinen Account zu aktivieren:

- - - -

Dieser Link ist 24 Stunden gültig. Falls du dich nicht bei ManaCore registriert hast, kannst du diese E-Mail ignorieren.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet.
- Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
- ${verificationUrl} -

- - -`, - }); -} - -/** - * Send account deletion confirmation email - */ -export async function sendAccountDeletionEmail(email: string, userName?: string): Promise { - const name = userName || email.split('@')[0]; - - return sendEmail({ - to: email, - subject: 'Dein ManaCore-Konto wurde gelöscht', - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo ${name},

- -

dein ManaCore-Konto und alle damit verbundenen Daten wurden erfolgreich gelöscht.

- -
-

Folgende Daten wurden entfernt:

-
    -
  • Benutzerprofil und Anmeldedaten
  • -
  • Alle Sessions und verknüpften Accounts
  • -
  • Credits und Transaktionshistorie
  • -
  • Daten in allen verbundenen Apps
  • -
-
- -

Diese Aktion ist unwiderruflich. Falls du ManaCore erneut nutzen möchtest, kannst du jederzeit ein neues Konto erstellen.

- -

Bei Fragen erreichst du uns unter support@mana.how.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet. -

- - -`, - }); -} - -/** - * Send magic link email for passwordless login - */ -export async function sendMagicLinkEmail(email: string, magicLinkUrl: string): Promise { - return sendEmail({ - to: email, - subject: 'Dein Login-Link für ManaCore', - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo,

- -

Klicke auf den Button unten, um dich bei ManaCore anzumelden:

- - - -

Dieser Link ist 10 Minuten gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.

- -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet.
- Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
- ${magicLinkUrl} -

- - -`, - text: `Klicke auf den folgenden Link, um dich anzumelden: ${magicLinkUrl}`, - }); -} - -/** - * Send welcome/verification email - */ -export async function sendWelcomeEmail( - email: string, - userName?: string, - verificationUrl?: string -): Promise { - const name = userName || email.split('@')[0]; - const hasVerification = !!verificationUrl; - - return sendEmail({ - to: email, - subject: 'Willkommen bei ManaCore!', - html: ` - - - - - - - -
-

ManaCore

-
- -

Hallo ${name},

- -

Willkommen bei ManaCore! Dein Account wurde erfolgreich erstellt.

- - ${ - hasVerification - ? ` -

Bitte bestätige deine E-Mail-Adresse, indem du auf den Button unten klickst:

- - - ` - : ` -

Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden.

- - - ` - } - -
- -

- Diese E-Mail wurde automatisch von ManaCore gesendet. -

- - -`, - }); -} diff --git a/services/mana-core-auth/src/feedback/dto/create-feedback.dto.ts b/services/mana-core-auth/src/feedback/dto/create-feedback.dto.ts deleted file mode 100644 index 76e3c148d..000000000 --- a/services/mana-core-auth/src/feedback/dto/create-feedback.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsString, IsOptional, MaxLength, MinLength, IsEnum, IsObject } from 'class-validator'; - -export class CreateFeedbackDto { - @IsString() - @IsOptional() - @MaxLength(100) - title?: string; - - @IsString() - @MinLength(10, { message: 'Feedback must be at least 10 characters long' }) - @MaxLength(2000, { message: 'Feedback must be at most 2000 characters long' }) - feedbackText: string; - - @IsEnum(['bug', 'feature', 'improvement', 'question', 'other']) - @IsOptional() - category?: 'bug' | 'feature' | 'improvement' | 'question' | 'other'; - - @IsObject() - @IsOptional() - deviceInfo?: Record; -} diff --git a/services/mana-core-auth/src/feedback/dto/feedback-query.dto.ts b/services/mana-core-auth/src/feedback/dto/feedback-query.dto.ts deleted file mode 100644 index 08fdf5595..000000000 --- a/services/mana-core-auth/src/feedback/dto/feedback-query.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { IsString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; -import { Transform } from 'class-transformer'; - -export class FeedbackQueryDto { - @IsString() - @IsOptional() - appId?: string; - - @IsEnum(['submitted', 'under_review', 'planned', 'in_progress', 'completed', 'declined']) - @IsOptional() - status?: string; - - @IsEnum(['bug', 'feature', 'improvement', 'question', 'other']) - @IsOptional() - category?: string; - - @IsEnum(['votes', 'recent']) - @IsOptional() - sort?: 'votes' | 'recent' = 'votes'; - - @Transform(({ value }) => parseInt(value, 10)) - @IsInt() - @Min(1) - @Max(50) - @IsOptional() - limit?: number = 20; - - @Transform(({ value }) => parseInt(value, 10)) - @IsInt() - @Min(0) - @IsOptional() - offset?: number = 0; -} diff --git a/services/mana-core-auth/src/feedback/dto/index.ts b/services/mana-core-auth/src/feedback/dto/index.ts deleted file mode 100644 index c305648d4..000000000 --- a/services/mana-core-auth/src/feedback/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CreateFeedbackDto } from './create-feedback.dto'; -export { FeedbackQueryDto } from './feedback-query.dto'; diff --git a/services/mana-core-auth/src/feedback/feedback.controller.ts b/services/mana-core-auth/src/feedback/feedback.controller.ts deleted file mode 100644 index b9346a2dc..000000000 --- a/services/mana-core-auth/src/feedback/feedback.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - Query, - UseGuards, - Headers, -} from '@nestjs/common'; -import { FeedbackService } from './feedback.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { OptionalAuthGuard } from '../common/guards/optional-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { CreateFeedbackDto } from './dto'; -import type { FeedbackQueryDto } from './dto'; - -@Controller('feedback') -export class FeedbackController { - constructor(private readonly feedbackService: FeedbackService) {} - - @Post() - @UseGuards(JwtAuthGuard) - async createFeedback( - @CurrentUser() user: CurrentUserData, - @Body() dto: CreateFeedbackDto, - @Headers('x-app-id') appIdHeader?: string - ) { - const appId = appIdHeader || 'unknown'; - return this.feedbackService.createFeedback(user.userId, appId, dto); - } - - @Get('public') - @UseGuards(OptionalAuthGuard) - async getPublicFeedback( - @CurrentUser() user: CurrentUserData | null, - @Query() query: FeedbackQueryDto - ) { - return this.feedbackService.getPublicFeedback(user?.userId || null, query); - } - - @Get('my') - @UseGuards(JwtAuthGuard) - async getMyFeedback(@CurrentUser() user: CurrentUserData, @Query('appId') appId?: string) { - return this.feedbackService.getMyFeedback(user.userId, appId); - } - - @Post(':id/vote') - @UseGuards(JwtAuthGuard) - async vote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) { - return this.feedbackService.vote(user.userId, feedbackId); - } - - @Delete(':id/vote') - @UseGuards(JwtAuthGuard) - async unvote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) { - return this.feedbackService.unvote(user.userId, feedbackId); - } -} diff --git a/services/mana-core-auth/src/feedback/feedback.module.ts b/services/mana-core-auth/src/feedback/feedback.module.ts deleted file mode 100644 index 2d8bc3f5e..000000000 --- a/services/mana-core-auth/src/feedback/feedback.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FeedbackController } from './feedback.controller'; -import { FeedbackService } from './feedback.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { OptionalAuthGuard } from '../common/guards/optional-auth.guard'; - -@Module({ - controllers: [FeedbackController], - providers: [FeedbackService, JwtAuthGuard, OptionalAuthGuard], - exports: [FeedbackService], -}) -export class FeedbackModule {} diff --git a/services/mana-core-auth/src/feedback/feedback.service.ts b/services/mana-core-auth/src/feedback/feedback.service.ts deleted file mode 100644 index c741ed164..000000000 --- a/services/mana-core-auth/src/feedback/feedback.service.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, desc, sql, count } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { userFeedback, feedbackVotes } from '../db/schema'; -import { CreateFeedbackDto } from './dto'; -import type { FeedbackQueryDto } from './dto'; -import { AiService } from '../ai/ai.service'; - -@Injectable() -export class FeedbackService { - private readonly logger = new Logger(FeedbackService.name); - - constructor( - private configService: ConfigService, - private aiService: AiService - ) {} - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - async createFeedback(userId: string, appId: string, dto: CreateFeedbackDto) { - const db = this.getDb(); - - // Use AI to generate title and category if not provided - let title = dto.title; - let category = dto.category; - - if (!title || !category) { - this.logger.debug('Analyzing feedback with AI...'); - const analysis = await this.aiService.analyzeFeedback(dto.feedbackText); - - if (!title) { - title = analysis.title; - } - if (!category) { - category = analysis.category; - } - this.logger.debug(`AI generated: title="${title}", category="${category}"`); - } - - const [feedback] = await db - .insert(userFeedback) - .values({ - userId, - appId, - title, - feedbackText: dto.feedbackText, - category: category || 'feature', - deviceInfo: dto.deviceInfo, - }) - .returning(); - - return { - success: true, - feedback: this.mapFeedback(feedback, false), - }; - } - - async getPublicFeedback(userId: string | null, query: FeedbackQueryDto) { - const db = this.getDb(); - const { appId, status, category, sort = 'votes', limit = 20, offset = 0 } = query; - - // Build conditions - const conditions = [eq(userFeedback.isPublic, true)]; - - if (appId) { - conditions.push(eq(userFeedback.appId, appId)); - } - if (status) { - conditions.push(eq(userFeedback.status, status as any)); - } - if (category) { - conditions.push(eq(userFeedback.category, category as any)); - } - - // Get feedback items - const feedbackItems = await db - .select() - .from(userFeedback) - .where(and(...conditions)) - .orderBy(sort === 'votes' ? desc(userFeedback.voteCount) : desc(userFeedback.createdAt)) - .limit(limit) - .offset(offset); - - // Get total count - const [{ total }] = await db - .select({ total: count() }) - .from(userFeedback) - .where(and(...conditions)); - - // Get user's votes (only if authenticated) - let votedFeedbackIds = new Set(); - if (userId) { - const feedbackIds = feedbackItems.map((f) => f.id); - const userVotes = - feedbackIds.length > 0 - ? await db - .select({ feedbackId: feedbackVotes.feedbackId }) - .from(feedbackVotes) - .where( - and( - eq(feedbackVotes.userId, userId), - sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})` - ) - ) - : []; - - votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId)); - } - - return { - success: true, - items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))), - total, - }; - } - - async getMyFeedback(userId: string, appId?: string) { - const db = this.getDb(); - - const conditions = [eq(userFeedback.userId, userId)]; - if (appId) { - conditions.push(eq(userFeedback.appId, appId)); - } - - const feedbackItems = await db - .select() - .from(userFeedback) - .where(and(...conditions)) - .orderBy(desc(userFeedback.createdAt)); - - // Get user's votes on their own feedback (for consistency) - const feedbackIds = feedbackItems.map((f) => f.id); - const userVotes = - feedbackIds.length > 0 - ? await db - .select({ feedbackId: feedbackVotes.feedbackId }) - .from(feedbackVotes) - .where( - and( - eq(feedbackVotes.userId, userId), - sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})` - ) - ) - : []; - - const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId)); - - return { - success: true, - items: feedbackItems.map((f) => this.mapFeedback(f, votedFeedbackIds.has(f.id))), - total: feedbackItems.length, - }; - } - - async vote(userId: string, feedbackId: string) { - const db = this.getDb(); - - // Check if feedback exists and is public - const [feedback] = await db - .select() - .from(userFeedback) - .where(eq(userFeedback.id, feedbackId)) - .limit(1); - - if (!feedback) { - throw new NotFoundException('Feedback not found'); - } - - if (!feedback.isPublic) { - throw new NotFoundException('Feedback not found or not public'); - } - - // Check if user already voted - const [existingVote] = await db - .select() - .from(feedbackVotes) - .where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId))) - .limit(1); - - if (existingVote) { - throw new ConflictException('Already voted'); - } - - // Add vote - await db.insert(feedbackVotes).values({ - feedbackId, - userId, - }); - - // Increment vote count - const [updated] = await db - .update(userFeedback) - .set({ - voteCount: sql`${userFeedback.voteCount} + 1`, - updatedAt: new Date(), - }) - .where(eq(userFeedback.id, feedbackId)) - .returning(); - - return { - success: true, - newVoteCount: updated.voteCount, - userHasVoted: true, - }; - } - - async unvote(userId: string, feedbackId: string) { - const db = this.getDb(); - - // Check if feedback exists - const [feedback] = await db - .select() - .from(userFeedback) - .where(eq(userFeedback.id, feedbackId)) - .limit(1); - - if (!feedback) { - throw new NotFoundException('Feedback not found'); - } - - // Check if user has voted - const [existingVote] = await db - .select() - .from(feedbackVotes) - .where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId))) - .limit(1); - - if (!existingVote) { - throw new NotFoundException('Vote not found'); - } - - // Remove vote - await db - .delete(feedbackVotes) - .where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId))); - - // Decrement vote count - const [updated] = await db - .update(userFeedback) - .set({ - voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)`, - updatedAt: new Date(), - }) - .where(eq(userFeedback.id, feedbackId)) - .returning(); - - return { - success: true, - newVoteCount: updated.voteCount, - userHasVoted: false, - }; - } - - private mapFeedback( - feedback: typeof userFeedback.$inferSelect, - userHasVoted: boolean - ): Record { - return { - id: feedback.id, - userId: feedback.userId, - appId: feedback.appId, - title: feedback.title, - feedbackText: feedback.feedbackText, - category: feedback.category, - status: feedback.status, - isPublic: feedback.isPublic, - adminResponse: feedback.adminResponse, - voteCount: feedback.voteCount, - userHasVoted, - deviceInfo: feedback.deviceInfo, - createdAt: feedback.createdAt.toISOString(), - updatedAt: feedback.updatedAt.toISOString(), - publishedAt: feedback.publishedAt?.toISOString(), - completedAt: feedback.completedAt?.toISOString(), - }; - } -} diff --git a/services/mana-core-auth/src/guilds/guilds.controller.ts b/services/mana-core-auth/src/guilds/guilds.controller.ts deleted file mode 100644 index 4fa370e05..000000000 --- a/services/mana-core-auth/src/guilds/guilds.controller.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Headers, - UseGuards, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { GuildsService, CreateGuildDto } from './guilds.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { UpdateOrganizationDto } from '../auth/dto/update-organization.dto'; - -class InviteMemberDto { - email: string; - role: 'admin' | 'member'; -} - -class AcceptInvitationBodyDto { - invitationId: string; -} - -@ApiTags('gilden') -@ApiBearerAuth('JWT-auth') -@Controller('gilden') -@UseGuards(JwtAuthGuard) -export class GuildsController { - constructor(private readonly guildsService: GuildsService) {} - - private extractToken(authorization: string): string { - return authorization?.replace('Bearer ', '') || ''; - } - - @Post() - @ApiOperation({ summary: 'Create a new guild' }) - @ApiResponse({ status: 201, description: 'Guild created with pool initialized' }) - async createGuild( - @Headers('authorization') authorization: string, - @CurrentUser() user: CurrentUserData, - @Body() dto: CreateGuildDto - ) { - const token = this.extractToken(authorization); - return this.guildsService.createGuild(token, user.userId, dto); - } - - @Get() - @ApiOperation({ summary: "List user's guilds" }) - @ApiResponse({ status: 200, description: 'Returns list of guilds with pool balances' }) - async listGuilds( - @Headers('authorization') authorization: string, - @CurrentUser() user: CurrentUserData - ) { - const token = this.extractToken(authorization); - return this.guildsService.listGuilds(token, user.userId); - } - - @Get(':id') - @ApiOperation({ summary: 'Get guild details with pool balance and members' }) - @ApiResponse({ status: 200, description: 'Returns guild details' }) - @ApiResponse({ status: 404, description: 'Guild not found' }) - async getGuild( - @Param('id') id: string, - @Headers('authorization') authorization: string, - @CurrentUser() user: CurrentUserData - ) { - const token = this.extractToken(authorization); - return this.guildsService.getGuild(id, token, user.userId); - } - - @Put(':id') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Update guild details' }) - @ApiResponse({ status: 200, description: 'Guild updated' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can update' }) - async updateGuild( - @Param('id') id: string, - @Headers('authorization') authorization: string, - @Body() dto: UpdateOrganizationDto - ) { - const token = this.extractToken(authorization); - return this.guildsService.updateGuild(id, dto, token); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete guild (cascades to pool)' }) - @ApiResponse({ status: 204, description: 'Guild deleted' }) - @ApiResponse({ status: 403, description: 'Only owners can delete' }) - async deleteGuild(@Param('id') id: string, @Headers('authorization') authorization: string) { - const token = this.extractToken(authorization); - await this.guildsService.deleteGuild(id, token); - } - - @Post(':id/invite') - @ApiOperation({ summary: 'Invite a member to the guild' }) - @ApiResponse({ status: 200, description: 'Invitation sent' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can invite' }) - async inviteMember( - @Param('id') guildId: string, - @Headers('authorization') authorization: string, - @CurrentUser() user: CurrentUserData, - @Body() dto: InviteMemberDto - ) { - const token = this.extractToken(authorization); - return this.guildsService.inviteMember(guildId, dto.email, dto.role, user.userId, token); - } - - @Post('accept-invitation') - @ApiOperation({ summary: 'Accept a guild invitation' }) - @ApiResponse({ status: 200, description: 'Invitation accepted' }) - async acceptInvitation( - @Headers('authorization') authorization: string, - @Body() dto: AcceptInvitationBodyDto - ) { - const token = this.extractToken(authorization); - return this.guildsService.acceptInvitation(dto.invitationId, token); - } - - @Delete(':id/members/:memberId') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Remove a member from the guild' }) - @ApiResponse({ status: 204, description: 'Member removed' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can remove members' }) - async removeMember( - @Param('id') guildId: string, - @Param('memberId') memberId: string, - @Headers('authorization') authorization: string - ) { - const token = this.extractToken(authorization); - await this.guildsService.removeMember(guildId, memberId, token); - } - - @Put(':id/members/:memberId/role') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Update member role' }) - @ApiResponse({ status: 200, description: 'Role updated' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can change roles' }) - async updateMemberRole( - @Param('id') guildId: string, - @Param('memberId') memberId: string, - @Headers('authorization') authorization: string, - @Body() dto: { role: string } - ) { - const token = this.extractToken(authorization); - return this.guildsService.updateMemberRole(guildId, memberId, dto.role, token); - } -} diff --git a/services/mana-core-auth/src/guilds/guilds.module.ts b/services/mana-core-auth/src/guilds/guilds.module.ts deleted file mode 100644 index 0b35779f4..000000000 --- a/services/mana-core-auth/src/guilds/guilds.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { GuildsController } from './guilds.controller'; -import { GuildsService } from './guilds.service'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [forwardRef(() => AuthModule)], - controllers: [GuildsController], - providers: [GuildsService], - exports: [GuildsService], -}) -export class GuildsModule {} diff --git a/services/mana-core-auth/src/guilds/guilds.service.ts b/services/mana-core-auth/src/guilds/guilds.service.ts deleted file mode 100644 index 6b955a570..000000000 --- a/services/mana-core-auth/src/guilds/guilds.service.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Injectable, BadRequestException, ForbiddenException, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, sql } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { members, organizations } from '../db/schema'; -import { subscriptions, plans } from '../db/schema/subscriptions.schema'; -import { BetterAuthService } from '../auth/services/better-auth.service'; -import { UpdateOrganizationDto } from '../auth/dto/update-organization.dto'; - -export class CreateGuildDto { - name: string; - slug?: string; - logo?: string; -} - -// Default limits for users without a subscription (free tier) -const FREE_TIER_LIMITS = { - maxOrganizations: 1, - maxTeamMembers: 1, // Just themselves -}; - -@Injectable() -export class GuildsService { - private readonly logger = new Logger(GuildsService.name); - - constructor( - private configService: ConfigService, - private betterAuthService: BetterAuthService - ) {} - - /** Get mana-credits service URL */ - private getCreditsUrl(): string { - return process.env.MANA_CREDITS_URL || 'http://localhost:3060'; - } - - private getServiceKey(): string { - return process.env.MANA_CORE_SERVICE_KEY || ''; - } - - /** Call mana-credits to get guild pool balance */ - private async getGuildPoolBalance(guildId: string, userId: string) { - try { - const creditsUrl = this.getCreditsUrl(); - // Use internal API with service key to get pool balance on behalf of user - const res = await fetch( - `${creditsUrl}/api/v1/internal/guild-pool/balance?guildId=${guildId}&userId=${userId}`, - { - headers: { 'X-Service-Key': this.getServiceKey() }, - } - ); - if (res.ok) return await res.json(); - } catch (error) { - this.logger.warn('Failed to get guild pool balance from mana-credits', { guildId }); - } - return { balance: 0, totalFunded: 0, totalSpent: 0 }; - } - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - /** - * Get the user's subscription plan limits. - * Falls back to free tier defaults if no active subscription. - */ - private async getUserPlanLimits(userId: string) { - const db = this.getDb(); - - const [sub] = await db - .select({ - maxTeamMembers: plans.maxTeamMembers, - maxOrganizations: plans.maxOrganizations, - }) - .from(subscriptions) - .innerJoin(plans, eq(subscriptions.planId, plans.id)) - .where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active'))) - .limit(1); - - return { - maxOrganizations: sub?.maxOrganizations ?? FREE_TIER_LIMITS.maxOrganizations, - maxTeamMembers: sub?.maxTeamMembers ?? FREE_TIER_LIMITS.maxTeamMembers, - }; - } - - /** - * Count how many guilds/organizations the user owns. - */ - private async countUserOwnedGuilds(userId: string): Promise { - const db = this.getDb(); - - const [result] = await db - .select({ count: sql`count(*)` }) - .from(members) - .where(and(eq(members.userId, userId), eq(members.role, 'owner'))); - - return Number(result.count); - } - - /** - * Count current members of a guild. - */ - private async countGuildMembers(guildId: string): Promise { - const db = this.getDb(); - - const [result] = await db - .select({ count: sql`count(*)` }) - .from(members) - .where(eq(members.organizationId, guildId)); - - return Number(result.count); - } - - /** - * Create a new guild (organization + pool). - * Enforces subscription limit for maxOrganizations. - */ - async createGuild(token: string, userId: string, dto: CreateGuildDto) { - // Check subscription limit - const limits = await this.getUserPlanLimits(userId); - const ownedGuilds = await this.countUserOwnedGuilds(userId); - - if (limits.maxOrganizations !== null && ownedGuilds >= limits.maxOrganizations) { - throw new BadRequestException( - `Guild limit reached. Your plan allows ${limits.maxOrganizations} guild(s). Upgrade to create more.` - ); - } - - // Create organization via Better Auth - const result = await this.betterAuthService.createOrganizationDirect(token, { - name: dto.name, - slug: dto.slug, - logo: dto.logo, - }); - - // Initialize the guild pool - // Initialize guild pool via mana-credits - let pool = { balance: 0, totalFunded: 0, totalSpent: 0 }; - try { - const creditsUrl = this.getCreditsUrl(); - const res = await fetch(`${creditsUrl}/api/v1/internal/guild-pool/init`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Service-Key': this.getServiceKey() }, - body: JSON.stringify({ organizationId: result.id }), - }); - if (res.ok) pool = await res.json(); - } catch { - this.logger.warn('Failed to init guild pool (non-critical)', { guildId: result.id }); - } - - this.logger.log('Guild created', { guildId: result.id, name: dto.name }); - - return { - gilde: { - id: result.id, - name: result.name, - slug: result.slug, - logo: result.logo, - createdAt: result.createdAt, - }, - pool: { - balance: pool.balance, - totalFunded: pool.totalFunded, - totalSpent: pool.totalSpent, - }, - }; - } - - /** - * List user's guilds with pool balances. - */ - async listGuilds(token: string, userId: string) { - const result = await this.betterAuthService.listOrganizations(token); - - const guilds = []; - - for (const org of result.organizations || []) { - try { - const pool = await this.getGuildPoolBalance(org.id, userId); - guilds.push({ - gilde: { - id: org.id, - name: org.name, - slug: org.slug, - logo: org.logo, - createdAt: org.createdAt, - }, - pool, - role: (org as any).role, - }); - } catch { - guilds.push({ - gilde: { - id: org.id, - name: org.name, - slug: org.slug, - logo: org.logo, - createdAt: org.createdAt, - }, - pool: null, - role: (org as any).role, - }); - } - } - - return { guilds }; - } - - /** - * Get guild details with pool balance and members. - */ - async getGuild(guildId: string, token: string, userId: string) { - const org = await this.betterAuthService.getOrganization(guildId, token); - let pool = null; - - try { - pool = await this.getGuildPoolBalance(guildId, userId); - } catch { - // Pool might not exist - } - - return { - gilde: { - id: org.id, - name: org.name, - slug: org.slug, - logo: org.logo, - metadata: org.metadata, - createdAt: org.createdAt, - }, - pool, - members: org.members, - }; - } - - /** - * Update guild details. - */ - async updateGuild(guildId: string, dto: UpdateOrganizationDto, token: string) { - return this.betterAuthService.updateOrganization(guildId, dto, token); - } - - /** - * Delete guild. Pool is cascade-deleted. - */ - async deleteGuild(guildId: string, token: string) { - return this.betterAuthService.deleteOrganization(guildId, token); - } - - /** - * Invite a member to the guild. - * Enforces subscription limit for maxTeamMembers. - */ - async inviteMember( - guildId: string, - email: string, - role: string, - inviterUserId: string, - token: string - ) { - // Find guild owner to check their subscription limits - const db = this.getDb(); - const [owner] = await db - .select() - .from(members) - .where(and(eq(members.organizationId, guildId), eq(members.role, 'owner'))) - .limit(1); - - if (owner) { - const limits = await this.getUserPlanLimits(owner.userId); - const memberCount = await this.countGuildMembers(guildId); - - if (limits.maxTeamMembers !== null && memberCount >= limits.maxTeamMembers) { - throw new BadRequestException( - `Member limit reached. The guild owner's plan allows ${limits.maxTeamMembers} member(s). Upgrade to invite more.` - ); - } - } - - return this.betterAuthService.inviteEmployee({ - organizationId: guildId, - employeeEmail: email, - role: role as 'admin' | 'member', - inviterToken: token, - }); - } - - /** - * Accept a guild invitation. - */ - async acceptInvitation(invitationId: string, token: string) { - return this.betterAuthService.acceptInvitation({ - invitationId, - userToken: token, - }); - } - - /** - * Remove a member from the guild. - */ - async removeMember(guildId: string, memberId: string, token: string) { - return this.betterAuthService.removeMember({ - organizationId: guildId, - memberId, - removerToken: token, - }); - } - - /** - * Update a member's role. - */ - async updateMemberRole(guildId: string, memberId: string, role: string, token: string) { - return this.betterAuthService.updateMemberRole( - guildId, - memberId, - role as 'admin' | 'member', - token - ); - } -} diff --git a/services/mana-core-auth/src/health/health.controller.ts b/services/mana-core-auth/src/health/health.controller.ts deleted file mode 100644 index 68f929d43..000000000 --- a/services/mana-core-auth/src/health/health.controller.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Health Check Controller - * - * Provides health check endpoints for Kubernetes/Docker: - * - /health - Basic health check (always returns ok if server is running) - * - /health/live - Liveness probe (is the process running?) - * - /health/ready - Readiness probe (is the service ready to accept traffic?) - * - * Readiness checks database connectivity to ensure the service - * can actually handle requests before receiving traffic. - */ - -import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { sql } from 'drizzle-orm'; -import { getDb } from '../db/connection'; - -interface HealthStatus { - status: 'ok' | 'error'; - timestamp: string; - uptime: number; - checks?: { - database?: { status: 'ok' | 'error'; latency?: number; error?: string }; - redis?: { status: 'ok' | 'error' | 'not_configured'; latency?: number; error?: string }; - }; -} - -@ApiTags('health') -@Controller('health') -export class HealthController { - private readonly startTime = Date.now(); - - constructor(private configService: ConfigService) {} - - /** - * Basic health check - * Returns ok if the server is running - */ - @Get() - @ApiOperation({ summary: 'Basic health check', description: 'Returns ok if server is running' }) - @ApiResponse({ status: 200, description: 'Service is healthy' }) - check(): HealthStatus { - return { - status: 'ok', - timestamp: new Date().toISOString(), - uptime: Math.floor((Date.now() - this.startTime) / 1000), - }; - } - - /** - * Liveness probe - * Used by Kubernetes to determine if the process should be restarted - * Only checks if the process is alive, not if dependencies are healthy - */ - @Get('live') - @ApiOperation({ - summary: 'Liveness probe', - description: 'Kubernetes liveness check - returns ok if process is alive', - }) - @ApiResponse({ status: 200, description: 'Process is alive' }) - live(): { status: 'ok' } { - return { status: 'ok' }; - } - - /** - * Readiness probe - * Used by Kubernetes to determine if the service should receive traffic - * Checks database connectivity before marking as ready - */ - @Get('ready') - @ApiOperation({ - summary: 'Readiness probe', - description: 'Kubernetes readiness check - verifies database and Redis connectivity', - }) - @ApiResponse({ status: 200, description: 'Service is ready to accept traffic' }) - @ApiResponse({ status: 503, description: 'Service is not ready (database or Redis unreachable)' }) - async ready(): Promise { - const checks: HealthStatus['checks'] = {}; - let allHealthy = true; - - // Check database - const dbCheck = await this.checkDatabase(); - checks.database = dbCheck; - if (dbCheck.status === 'error') { - allHealthy = false; - } - - // Check Redis (optional - don't fail if not configured) - const redisCheck = await this.checkRedis(); - checks.redis = redisCheck; - // Don't fail readiness if Redis is just not configured - if (redisCheck.status === 'error') { - allHealthy = false; - } - - const status: HealthStatus = { - status: allHealthy ? 'ok' : 'error', - timestamp: new Date().toISOString(), - uptime: Math.floor((Date.now() - this.startTime) / 1000), - checks, - }; - - if (!allHealthy) { - throw new ServiceUnavailableException(status); - } - - return status; - } - - /** - * Check database connectivity - */ - private async checkDatabase(): Promise<{ - status: 'ok' | 'error'; - latency?: number; - error?: string; - }> { - const start = Date.now(); - - try { - const databaseUrl = this.configService.get('database.url'); - if (!databaseUrl) { - return { status: 'error', error: 'DATABASE_URL not configured' }; - } - - const db = getDb(databaseUrl); - await db.execute(sql`SELECT 1`); - - return { - status: 'ok', - latency: Date.now() - start, - }; - } catch (error) { - return { - status: 'error', - latency: Date.now() - start, - error: error instanceof Error ? error.message : 'Unknown database error', - }; - } - } - - /** - * Check Redis connectivity (optional) - */ - private async checkRedis(): Promise<{ - status: 'ok' | 'error' | 'not_configured'; - latency?: number; - error?: string; - }> { - const redisHost = this.configService.get('redis.host'); - - // Redis is optional - if not configured, that's fine - if (!redisHost) { - return { status: 'not_configured' }; - } - - const start = Date.now(); - - try { - // Simple TCP connection check to Redis - const net = await import('net'); - const redisPort = this.configService.get('redis.port') || 6379; - - await new Promise((resolve, reject) => { - const socket = new net.Socket(); - const timeout = setTimeout(() => { - socket.destroy(); - reject(new Error('Connection timeout')); - }, 2000); - - socket.connect(redisPort, redisHost, () => { - clearTimeout(timeout); - socket.destroy(); - resolve(); - }); - - socket.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - }); - - return { - status: 'ok', - latency: Date.now() - start, - }; - } catch (error) { - return { - status: 'error', - latency: Date.now() - start, - error: error instanceof Error ? error.message : 'Unknown Redis error', - }; - } - } -} diff --git a/services/mana-core-auth/src/health/health.module.ts b/services/mana-core-auth/src/health/health.module.ts deleted file mode 100644 index a61d8b044..000000000 --- a/services/mana-core-auth/src/health/health.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; - -@Module({ - controllers: [HealthController], -}) -export class HealthModule {} diff --git a/services/mana-core-auth/src/main.ts b/services/mana-core-auth/src/main.ts deleted file mode 100644 index 984121dcd..000000000 --- a/services/mana-core-auth/src/main.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, RequestMethod } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import type { Request, Response, NextFunction } from 'express'; -import helmet from 'helmet'; -import cookieParser from 'cookie-parser'; -import * as bodyParser from 'body-parser'; -import { AppModule } from './app.module'; -import { MetricsService } from './metrics/metrics.service'; -import { getLogger } from './common/logger'; - -const logger = getLogger('Bootstrap'); - -// Normalize route paths to prevent high cardinality -function normalizeRoute(path: string): string { - return path - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') - .replace(/\/\d+/g, '/:id'); -} - -async function bootstrap() { - const app = await NestFactory.create(AppModule, { - rawBody: true, // Enable raw body for Stripe webhook signature verification - }); - - const configService = app.get(ConfigService); - - // Get MetricsService for request tracking - const metricsService = app.get(MetricsService); - - // Global Express middleware to track ALL HTTP requests - app.use((req: Request, res: Response, next: NextFunction) => { - if (req.path === '/metrics') { - return next(); - } - - const startTime = Date.now(); - const method = req.method; - const route = normalizeRoute(req.path); - - res.once('finish', () => { - const duration = (Date.now() - startTime) / 1000; - metricsService.httpRequestsTotal.inc({ - method, - route, - status: res.statusCode.toString(), - }); - metricsService.httpRequestDuration.observe( - { method, route, status: res.statusCode.toString() }, - duration - ); - }); - - next(); - }); - - // Security middleware - configure helmet to allow CORS and inline scripts for login page - app.use( - helmet({ - crossOriginResourcePolicy: { policy: 'cross-origin' }, - crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for login page - styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles - imgSrc: ["'self'", 'data:', 'https:'], - connectSrc: ["'self'"], - fontSrc: ["'self'"], - objectSrc: ["'none'"], - frameAncestors: ["'none'"], - }, - }, - }) - ); - app.use(cookieParser()); - - // Body parser for form-urlencoded (needed for OAuth2 token endpoint) - // Note: JSON body parsing is handled by NestJS internally (rawBody: true mode) - // DO NOT add bodyParser.json() here - it conflicts with NestJS rawBody mode - // and causes "stream is not readable" errors - app.use(bodyParser.urlencoded({ extended: true })); - - // CORS configuration - const corsOrigins = configService.get('cors.origin') || []; - logger.info('CORS Origins configured', { origins: corsOrigins }); - app.enableCors({ - origin: corsOrigins, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-App-Id'], - }); - - // Global validation pipe - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - transformOptions: { - enableImplicitConversion: true, - }, - }) - ); - - // Global prefix (exclude metrics, health, Better Auth native routes, and OIDC routes) - // Better Auth generates verification URLs with /api/auth/* prefix - // OIDC Provider requires routes without prefix: /.well-known/*, /api/auth/oauth2/*, /api/oidc/* - app.setGlobalPrefix('api/v1', { - exclude: [ - { path: 'metrics', method: RequestMethod.ALL }, - { path: 'health', method: RequestMethod.ALL }, - // OIDC login page - { path: 'login', method: RequestMethod.ALL }, - // Better Auth routes (verification emails, password reset, sign-in, SSO) - { path: 'api/auth/get-session', method: RequestMethod.ALL }, - { path: 'api/auth/verify-email', method: RequestMethod.ALL }, - { path: 'api/auth/reset-password/(.*)', method: RequestMethod.ALL }, - { path: 'api/auth/sign-in/(.*)', method: RequestMethod.ALL }, - // Better Auth OIDC/OAuth2 routes (native paths from discovery document) - { path: 'api/auth/jwks', method: RequestMethod.ALL }, - { path: 'api/auth/oauth2/(.*)', method: RequestMethod.ALL }, - { path: 'api/auth/oauth2/authorize', method: RequestMethod.ALL }, - { path: 'api/auth/oauth2/token', method: RequestMethod.ALL }, - { path: 'api/auth/oauth2/userinfo', method: RequestMethod.ALL }, - { path: 'api/auth/oauth2/:path*', method: RequestMethod.ALL }, - // OIDC discovery - { path: '.well-known/(.*)', method: RequestMethod.ALL }, - { path: '.well-known/openid-configuration', method: RequestMethod.ALL }, - // Alternative OIDC routes - { path: 'api/oidc/(.*)', method: RequestMethod.ALL }, - { path: 'api/oidc/:path*', method: RequestMethod.ALL }, - ], - }); - - // 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 -\`\`\` - -### 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('port') || 3001; - await app.listen(port); - - logger.info(`Mana Core Auth running on http://localhost:${port}`, { - port, - environment: configService.get('nodeEnv'), - docs: `http://localhost:${port}/api-docs`, - }); -} - -bootstrap(); diff --git a/services/mana-core-auth/src/me/me.controller.ts b/services/mana-core-auth/src/me/me.controller.ts deleted file mode 100644 index 37fe7a450..000000000 --- a/services/mana-core-auth/src/me/me.controller.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Controller, Get, Delete, UseGuards, Res, HttpStatus } from '@nestjs/common'; -import type { Response } from 'express'; -import { MeService } from './me.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser, type CurrentUserData } from '../common/decorators/current-user.decorator'; - -/** - * Self-service endpoints for users to manage their own data. - * GDPR compliance: view, export, and delete personal data. - * - * All endpoints require authentication via JwtAuthGuard. - * User ID is extracted from the JWT token - no userId parameter needed. - */ -@Controller('me') -@UseGuards(JwtAuthGuard) -export class MeController { - constructor(private readonly meService: MeService) {} - - /** - * Get the authenticated user's data summary. - * Returns aggregated data from auth, credits, and all connected project backends. - */ - @Get('data') - async getMyData(@CurrentUser() user: CurrentUserData) { - return this.meService.getMyData(user.userId); - } - - /** - * Export the authenticated user's data as a JSON file download. - * GDPR Article 20: Right to data portability. - */ - @Get('data/export') - async exportMyData(@CurrentUser() user: CurrentUserData, @Res() res: Response) { - const exportData = await this.meService.exportMyData(user.userId); - - const filename = `meine-daten-${new Date().toISOString().split('T')[0]}.json`; - - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.status(HttpStatus.OK).send(JSON.stringify(exportData, null, 2)); - } - - /** - * Delete all data for the authenticated user. - * GDPR Article 17: Right to erasure ("right to be forgotten"). - * - * This performs a soft-delete of the user account and hard-deletes all associated data. - * The operation is irreversible. - */ - @Delete('data') - async deleteMyData(@CurrentUser() user: CurrentUserData) { - return this.meService.deleteMyData(user.userId); - } -} diff --git a/services/mana-core-auth/src/me/me.module.ts b/services/mana-core-auth/src/me/me.module.ts deleted file mode 100644 index 7a9ff4bf5..000000000 --- a/services/mana-core-auth/src/me/me.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { ConfigModule } from '@nestjs/config'; -import { MeController } from './me.controller'; -import { MeService } from './me.service'; -import { AdminModule } from '../admin/admin.module'; - -@Module({ - imports: [ - ConfigModule, - HttpModule.register({ - timeout: 5000, - maxRedirects: 3, - }), - AdminModule, - ], - controllers: [MeController], - providers: [MeService], -}) -export class MeModule {} diff --git a/services/mana-core-auth/src/me/me.service.ts b/services/mana-core-auth/src/me/me.service.ts deleted file mode 100644 index 9125f2428..000000000 --- a/services/mana-core-auth/src/me/me.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { UserDataService } from '../admin/user-data.service'; -import type { UserDataSummary, DeleteUserDataResponse } from '../admin/dto/user-data.dto'; -import { sendAccountDeletionEmail } from '../email/email.service'; - -/** - * Self-service data management for authenticated users. - * Wraps UserDataService to allow users to access their own data without admin privileges. - */ -@Injectable() -export class MeService { - private readonly logger = new Logger(MeService.name); - - constructor(private readonly userDataService: UserDataService) {} - - /** - * Get the authenticated user's data summary - */ - async getMyData(userId: string): Promise { - this.logger.log(`User ${userId} requesting own data`); - return this.userDataService.getUserDataSummary(userId); - } - - /** - * Export the authenticated user's data as a complete JSON object - * Includes sessions, security events, and credit transactions for GDPR compliance - */ - async exportMyData(userId: string): Promise { - this.logger.log(`User ${userId} exporting own data`); - return this.userDataService.getFullExportData(userId); - } - - /** - * Delete all data for the authenticated user (GDPR right to be forgotten) - * Sends confirmation email after successful deletion - */ - async deleteMyData(userId: string): Promise { - this.logger.log(`User ${userId} requesting deletion of own data`); - - // Get user data BEFORE deletion for sending confirmation email - const user = await this.userDataService.getUserForEmail(userId); - - // Perform deletion - const result = await this.userDataService.deleteUserData(userId); - - // Send confirmation email if deletion was successful - if (result.success && user?.email) { - try { - await sendAccountDeletionEmail(user.email, user.name || undefined); - this.logger.log(`Account deletion confirmation email sent to ${user.email}`); - } catch (error) { - // Log but don't fail the deletion if email fails - this.logger.error(`Failed to send deletion confirmation email to ${user.email}`, error); - } - } - - return result; - } -} - -export interface UserDataExport { - exportedAt: string; - exportVersion: string; - data: UserDataSummary; -} - -export interface SessionExport { - id: string; - createdAt: Date; - expiresAt: Date; - lastActivityAt: Date | null; - ipAddress: string | null; - userAgent: string | null; - deviceName: string | null; - revokedAt: Date | null; -} - -export interface SecurityEventExport { - id: string; - eventType: string; - ipAddress: string | null; - userAgent: string | null; - metadata: unknown; - createdAt: Date; -} - -export interface TransactionExport { - id: string; - type: string; - status: string; - amount: number; - balanceBefore: number; - balanceAfter: number; - appId: string; - description: string; - createdAt: Date; - completedAt: Date | null; -} - -export interface FullUserDataExport extends UserDataSummary { - exportedAt: string; - exportVersion: string; - sessions: { - active: SessionExport[]; - history: SessionExport[]; - }; - securityEvents: SecurityEventExport[]; - creditTransactions: TransactionExport[]; -} diff --git a/services/mana-core-auth/src/metrics/index.ts b/services/mana-core-auth/src/metrics/index.ts deleted file mode 100644 index 860cd0cdf..000000000 --- a/services/mana-core-auth/src/metrics/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './metrics.module'; -export * from './metrics.service'; -export * from './metrics.controller'; diff --git a/services/mana-core-auth/src/metrics/metrics.controller.ts b/services/mana-core-auth/src/metrics/metrics.controller.ts deleted file mode 100644 index eb3168a67..000000000 --- a/services/mana-core-auth/src/metrics/metrics.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get, Header } from '@nestjs/common'; -import { MetricsService } from './metrics.service'; - -@Controller() -export class MetricsController { - constructor(private readonly metricsService: MetricsService) {} - - @Get('metrics') - @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') - async getMetrics(): Promise { - return this.metricsService.getMetrics(); - } -} diff --git a/services/mana-core-auth/src/metrics/metrics.module.ts b/services/mana-core-auth/src/metrics/metrics.module.ts deleted file mode 100644 index 32b20829b..000000000 --- a/services/mana-core-auth/src/metrics/metrics.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { MetricsService } from './metrics.service'; -import { MetricsController } from './metrics.controller'; - -@Global() -@Module({ - controllers: [MetricsController], - providers: [MetricsService], - exports: [MetricsService], -}) -export class MetricsModule {} diff --git a/services/mana-core-auth/src/metrics/metrics.service.ts b/services/mana-core-auth/src/metrics/metrics.service.ts deleted file mode 100644 index 6bdccbea2..000000000 --- a/services/mana-core-auth/src/metrics/metrics.service.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import * as client from 'prom-client'; -import { count, eq, gte, and, isNull, sql } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { users } from '../db/schema'; - -@Injectable() -export class MetricsService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(MetricsService.name); - private readonly register: client.Registry; - private updateInterval: ReturnType | null = null; - - // HTTP metrics - readonly httpRequestsTotal: client.Counter; - readonly httpRequestDuration: client.Histogram; - - // User metrics - readonly usersTotal: client.Gauge; - readonly usersVerified: client.Gauge; - readonly usersCreatedToday: client.Gauge; - readonly usersCreatedThisWeek: client.Gauge; - readonly usersCreatedThisMonth: client.Gauge; - - constructor(private readonly configService: ConfigService) { - this.register = new client.Registry(); - - // Add default metrics (CPU, memory, event loop, etc.) - client.collectDefaultMetrics({ - register: this.register, - prefix: 'auth_', - }); - - // HTTP request counter - this.httpRequestsTotal = new client.Counter({ - name: 'http_requests_total', - help: 'Total number of HTTP requests', - labelNames: ['method', 'route', 'status'], - registers: [this.register], - }); - - // HTTP request duration histogram - this.httpRequestDuration = new client.Histogram({ - name: 'http_request_duration_seconds', - help: 'Duration of HTTP requests in seconds', - labelNames: ['method', 'route', 'status'], - buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], - registers: [this.register], - }); - - // User statistics gauges - this.usersTotal = new client.Gauge({ - name: 'auth_users_total', - help: 'Total number of registered users', - registers: [this.register], - }); - - this.usersVerified = new client.Gauge({ - name: 'auth_users_verified', - help: 'Number of email-verified users', - registers: [this.register], - }); - - this.usersCreatedToday = new client.Gauge({ - name: 'auth_users_created_today', - help: 'Number of users created today', - registers: [this.register], - }); - - this.usersCreatedThisWeek = new client.Gauge({ - name: 'auth_users_created_this_week', - help: 'Number of users created this week', - registers: [this.register], - }); - - this.usersCreatedThisMonth = new client.Gauge({ - name: 'auth_users_created_this_month', - help: 'Number of users created this month', - registers: [this.register], - }); - } - - async onModuleInit() { - // Update user metrics immediately and then every 60 seconds - await this.updateUserMetrics(); - this.updateInterval = setInterval(() => this.updateUserMetrics(), 60000); - } - - onModuleDestroy() { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } - } - - private async updateUserMetrics() { - const databaseUrl = this.configService.get('DATABASE_URL'); - if (!databaseUrl) { - this.logger.warn('DATABASE_URL not configured, user metrics unavailable'); - return; - } - - try { - const db = getDb(databaseUrl); - const now = new Date(); - - // Start of today (midnight) - const startOfToday = new Date(now); - startOfToday.setHours(0, 0, 0, 0); - - // Start of week (Monday) - const startOfWeek = new Date(now); - const day = startOfWeek.getDay(); - const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); - startOfWeek.setDate(diff); - startOfWeek.setHours(0, 0, 0, 0); - - // Start of month - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - - // Query all metrics in parallel - const [totalResult, verifiedResult, todayResult, weekResult, monthResult] = await Promise.all( - [ - // Total users - db.select({ count: count() }).from(users).where(isNull(users.deletedAt)), - // Verified users - db - .select({ count: count() }) - .from(users) - .where(and(isNull(users.deletedAt), eq(users.emailVerified, true))), - // Users created today - db - .select({ count: count() }) - .from(users) - .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfToday))), - // Users created this week - db - .select({ count: count() }) - .from(users) - .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfWeek))), - // Users created this month - db - .select({ count: count() }) - .from(users) - .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfMonth))), - ] - ); - - this.usersTotal.set(totalResult[0].count); - this.usersVerified.set(verifiedResult[0].count); - this.usersCreatedToday.set(todayResult[0].count); - this.usersCreatedThisWeek.set(weekResult[0].count); - this.usersCreatedThisMonth.set(monthResult[0].count); - } catch (error) { - this.logger.error('Failed to update user metrics:', error); - } - } - - async getMetrics(): Promise { - return this.register.metrics(); - } - - getContentType(): string { - return this.register.contentType; - } -} diff --git a/services/mana-core-auth/src/security/account-lockout.service.ts b/services/mana-core-auth/src/security/account-lockout.service.ts deleted file mode 100644 index ac5058e63..000000000 --- a/services/mana-core-auth/src/security/account-lockout.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Account Lockout Service - * - * Tracks failed login attempts and locks accounts after too many failures. - * Uses the login_attempts table for efficient counting. - * - * Policy: - * - 5 failed attempts within 15 minutes → account locked for 30 minutes - * - Successful login clears all previous attempts - * - Lockout is per-email (not per-IP) to prevent distributed brute force - */ - -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb } from '../db/connection'; -import { loginAttempts } from '../db/schema/login-attempts.schema'; -import { LoggerService } from '../common/logger'; -import { and, eq, gte, sql, desc } from 'drizzle-orm'; - -const MAX_ATTEMPTS = 5; -const ATTEMPT_WINDOW_MINUTES = 15; -const LOCKOUT_DURATION_MINUTES = 30; - -export interface LockoutStatus { - locked: boolean; - remainingSeconds?: number; - attempts?: number; -} - -@Injectable() -export class AccountLockoutService { - private readonly logger: LoggerService; - private readonly databaseUrl: string; - - constructor( - loggerService: LoggerService, - private configService: ConfigService - ) { - this.logger = loggerService; - this.logger.setContext('AccountLockoutService'); - this.databaseUrl = this.configService.get('database.url') || ''; - } - - private getDb() { - return getDb(this.databaseUrl); - } - - /** - * Check if an account is locked due to too many failed login attempts - */ - async checkLockout(email: string): Promise { - try { - const db = this.getDb(); - const windowStart = new Date(Date.now() - ATTEMPT_WINDOW_MINUTES * 60 * 1000); - - // Count failed attempts in the window - const result = await db - .select({ - count: sql`count(*)::int`, - latestAttempt: sql`max(${loginAttempts.attemptedAt})`, - }) - .from(loginAttempts) - .where( - and( - eq(loginAttempts.email, email.toLowerCase()), - eq(loginAttempts.successful, false), - gte(loginAttempts.attemptedAt, windowStart) - ) - ); - - const failedCount = result[0]?.count ?? 0; - const latestAttempt = result[0]?.latestAttempt; - - if (failedCount >= MAX_ATTEMPTS && latestAttempt) { - const lockoutEnd = new Date( - new Date(latestAttempt).getTime() + LOCKOUT_DURATION_MINUTES * 60 * 1000 - ); - const remainingMs = lockoutEnd.getTime() - Date.now(); - - if (remainingMs > 0) { - return { - locked: true, - remainingSeconds: Math.ceil(remainingMs / 1000), - attempts: failedCount, - }; - } - } - - return { locked: false, attempts: failedCount }; - } catch (error) { - // On error, do not lock out (fail open for availability) - this.logger.warn('Failed to check lockout status', { - email, - error: error instanceof Error ? error.message : 'Unknown error', - }); - return { locked: false }; - } - } - - /** - * Record a login attempt (successful or failed) - */ - async recordAttempt(email: string, successful: boolean, ipAddress?: string): Promise { - try { - const db = this.getDb(); - await db.insert(loginAttempts).values({ - email: email.toLowerCase(), - ipAddress: ipAddress || null, - successful, - }); - } catch (error) { - this.logger.warn('Failed to record login attempt', { - email, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - /** - * Clear all failed attempts for an email (called on successful login) - */ - async clearAttempts(email: string): Promise { - try { - const db = this.getDb(); - const windowStart = new Date(Date.now() - LOCKOUT_DURATION_MINUTES * 60 * 1000); - await db - .delete(loginAttempts) - .where( - and( - eq(loginAttempts.email, email.toLowerCase()), - gte(loginAttempts.attemptedAt, windowStart) - ) - ); - } catch (error) { - this.logger.warn('Failed to clear login attempts', { - email, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } -} diff --git a/services/mana-core-auth/src/security/index.ts b/services/mana-core-auth/src/security/index.ts deleted file mode 100644 index d9cbcecf6..000000000 --- a/services/mana-core-auth/src/security/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { SecurityModule } from './security.module'; -export { SecurityEventsService, SecurityEventType } from './security-events.service'; -export type { SecurityEventParams, SecurityEventTypeValue } from './security-events.service'; -export { AccountLockoutService } from './account-lockout.service'; -export type { LockoutStatus } from './account-lockout.service'; diff --git a/services/mana-core-auth/src/security/security-events.service.ts b/services/mana-core-auth/src/security/security-events.service.ts deleted file mode 100644 index 6cd11b610..000000000 --- a/services/mana-core-auth/src/security/security-events.service.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Security Events Service - * - * Centralized audit logging for all authentication and security-relevant events. - * All methods are fire-and-forget: errors are logged but never thrown, - * so audit logging cannot break authentication flows. - */ - -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb } from '../db/connection'; -import { securityEvents } from '../db/schema/auth.schema'; -import { LoggerService } from '../common/logger'; -import type { Request } from 'express'; - -export const SecurityEventType = { - // Authentication - LOGIN_SUCCESS: 'login_success', - LOGIN_FAILURE: 'login_failure', - REGISTER: 'register', - LOGOUT: 'logout', - TOKEN_REFRESHED: 'token_refreshed', - SSO_TOKEN_EXCHANGE: 'sso_token_exchange', - - // Password - PASSWORD_CHANGED: 'password_changed', - PASSWORD_RESET_REQUESTED: 'password_reset_requested', - PASSWORD_RESET_COMPLETED: 'password_reset_completed', - - // Email - EMAIL_VERIFIED: 'email_verified', - EMAIL_VERIFICATION_RESENT: 'email_verification_resent', - - // Account - ACCOUNT_DELETED: 'account_deleted', - ACCOUNT_LOCKED: 'account_locked', - ACCOUNT_UNLOCKED: 'account_unlocked', - PROFILE_UPDATED: 'profile_updated', - - // API Keys - API_KEY_CREATED: 'api_key_created', - API_KEY_REVOKED: 'api_key_revoked', - API_KEY_VALIDATED: 'api_key_validated', - API_KEY_VALIDATION_FAILED: 'api_key_validation_failed', - - // Passkeys - PASSKEY_REGISTERED: 'passkey_registered', - PASSKEY_LOGIN_SUCCESS: 'passkey_login_success', - PASSKEY_LOGIN_FAILURE: 'passkey_login_failure', - PASSKEY_DELETED: 'passkey_deleted', - - // Two-Factor Authentication - TWO_FACTOR_ENABLED: 'two_factor_enabled', - TWO_FACTOR_DISABLED: 'two_factor_disabled', - TWO_FACTOR_VERIFIED: 'two_factor_verified', - - // Organizations - ORG_CREATED: 'org_created', - ORG_DELETED: 'org_deleted', - ORG_MEMBER_INVITED: 'org_member_invited', - ORG_MEMBER_REMOVED: 'org_member_removed', - ORG_MEMBER_ROLE_CHANGED: 'org_member_role_changed', - ORG_INVITATION_ACCEPTED: 'org_invitation_accepted', -} as const; - -export type SecurityEventTypeValue = (typeof SecurityEventType)[keyof typeof SecurityEventType]; - -export interface SecurityEventParams { - userId?: string; - eventType: SecurityEventTypeValue; - ipAddress?: string; - userAgent?: string; - metadata?: Record; -} - -@Injectable() -export class SecurityEventsService { - private readonly logger: LoggerService; - private readonly databaseUrl: string; - - constructor( - loggerService: LoggerService, - private configService: ConfigService - ) { - this.logger = loggerService; - this.logger.setContext('SecurityEventsService'); - this.databaseUrl = this.configService.get('database.url') || ''; - } - - /** - * Extract IP address and User-Agent from an Express request - */ - extractRequestInfo(req: Request): { ipAddress: string; userAgent: string } { - const forwarded = req.headers['x-forwarded-for']; - const ipAddress = - (typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.ip) || 'unknown'; - const userAgent = (req.headers['user-agent'] as string) || 'unknown'; - return { ipAddress, userAgent }; - } - - /** - * Log a security event to the database. - * Fire-and-forget: never throws, only logs warnings on failure. - */ - async logEvent(params: SecurityEventParams): Promise { - try { - const db = getDb(this.databaseUrl); - await db.insert(securityEvents).values({ - userId: params.userId || null, - eventType: params.eventType, - ipAddress: params.ipAddress || null, - userAgent: params.userAgent || null, - metadata: params.metadata || null, - }); - } catch (error) { - this.logger.warn(`Failed to log security event: ${params.eventType}`, { - error: error instanceof Error ? error.message : 'Unknown error', - userId: params.userId, - }); - } - } - - /** - * Convenience: log event with request context - */ - async logEventWithRequest( - req: Request, - params: Omit - ): Promise { - const { ipAddress, userAgent } = this.extractRequestInfo(req); - await this.logEvent({ ...params, ipAddress, userAgent }); - } -} diff --git a/services/mana-core-auth/src/security/security-events.spec.ts b/services/mana-core-auth/src/security/security-events.spec.ts deleted file mode 100644 index 1ad2fc375..000000000 --- a/services/mana-core-auth/src/security/security-events.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Security Events Service Tests - */ - -import * as fs from 'fs'; -import * as path from 'path'; - -describe('SecurityEventsService contract', () => { - const servicePath = path.resolve(__dirname, 'security-events.service.ts'); - let serviceContent: string; - - beforeAll(() => { - serviceContent = fs.readFileSync(servicePath, 'utf8'); - }); - - describe('event types', () => { - const requiredEvents = [ - 'login_success', - 'login_failure', - 'register', - 'logout', - 'token_refreshed', - 'sso_token_exchange', - 'password_changed', - 'password_reset_requested', - 'password_reset_completed', - 'email_verified', - 'account_deleted', - 'account_locked', - 'api_key_created', - 'api_key_revoked', - 'api_key_validated', - 'api_key_validation_failed', - 'org_created', - 'org_member_invited', - 'org_member_removed', - ]; - - it.each(requiredEvents)('should define event type: %s', (eventType) => { - expect(serviceContent).toContain(`'${eventType}'`); - }); - }); - - describe('fire-and-forget pattern', () => { - it('should catch errors in logEvent and never throw', () => { - // The logEvent method must have a try-catch that logs warnings - expect(serviceContent).toContain('catch (error)'); - expect(serviceContent).toContain('Failed to log security event'); - }); - }); - - describe('request info extraction', () => { - it('should extract IP from x-forwarded-for header', () => { - expect(serviceContent).toContain('x-forwarded-for'); - }); - - it('should extract user-agent from request', () => { - expect(serviceContent).toContain('user-agent'); - }); - }); -}); - -describe('AccountLockoutService contract', () => { - const servicePath = path.resolve(__dirname, 'account-lockout.service.ts'); - let serviceContent: string; - - beforeAll(() => { - serviceContent = fs.readFileSync(servicePath, 'utf8'); - }); - - it('should define MAX_ATTEMPTS = 5', () => { - expect(serviceContent).toContain('MAX_ATTEMPTS = 5'); - }); - - it('should define ATTEMPT_WINDOW_MINUTES = 15', () => { - expect(serviceContent).toContain('ATTEMPT_WINDOW_MINUTES = 15'); - }); - - it('should define LOCKOUT_DURATION_MINUTES = 30', () => { - expect(serviceContent).toContain('LOCKOUT_DURATION_MINUTES = 30'); - }); - - it('should normalize email to lowercase', () => { - expect(serviceContent).toContain('email.toLowerCase()'); - }); - - it('should fail open on errors (not lock users out if DB fails)', () => { - // On error, checkLockout should return locked: false - expect(serviceContent).toContain('return { locked: false }'); - }); - - it('should clear attempts on successful login', () => { - expect(serviceContent).toContain('clearAttempts'); - expect(serviceContent).toContain('delete(loginAttempts)'); - }); -}); - -describe('Auth Controller lockout integration', () => { - const controllerPath = path.resolve(__dirname, '../auth/auth.controller.ts'); - let controllerContent: string; - - beforeAll(() => { - controllerContent = fs.readFileSync(controllerPath, 'utf8'); - }); - - it('should check lockout before attempting login', () => { - expect(controllerContent).toContain('accountLockout.checkLockout'); - }); - - it('should throw ForbiddenException with ACCOUNT_LOCKED code when locked', () => { - expect(controllerContent).toContain("code: 'ACCOUNT_LOCKED'"); - }); - - it('should include retryAfter in lockout response', () => { - expect(controllerContent).toContain('retryAfter: lockout.remainingSeconds'); - }); - - it('should clear attempts after successful login', () => { - expect(controllerContent).toContain('accountLockout.clearAttempts'); - }); - - it('should record failed attempts on login failure', () => { - expect(controllerContent).toContain('accountLockout.recordAttempt'); - }); - - it('should not count email-not-verified as failed attempt', () => { - expect(controllerContent).toContain('ForbiddenException'); - // The catch block should re-throw ForbiddenException before recording attempt - const loginMethodContent = controllerContent.slice( - controllerContent.indexOf('async login('), - controllerContent.indexOf('async logout(') - ); - const forbiddenCheckIndex = loginMethodContent.indexOf('instanceof ForbiddenException'); - const recordAttemptIndex = loginMethodContent.indexOf('recordAttempt'); - expect(forbiddenCheckIndex).toBeLessThan(recordAttemptIndex); - }); -}); - -describe('API Key validation rate limiting', () => { - const controllerPath = path.resolve(__dirname, '../api-keys/api-keys.controller.ts'); - let controllerContent: string; - - beforeAll(() => { - controllerContent = fs.readFileSync(controllerPath, 'utf8'); - }); - - it('should have rate limiting on validate endpoint', () => { - expect(controllerContent).toContain('@Throttle'); - expect(controllerContent).toContain('limit: 10'); - }); - - it('should use ThrottlerGuard', () => { - expect(controllerContent).toContain('ThrottlerGuard'); - }); - - it('should log successful and failed validations', () => { - expect(controllerContent).toContain('API_KEY_VALIDATED'); - expect(controllerContent).toContain('API_KEY_VALIDATION_FAILED'); - }); - - it('should only log key prefix, never the full key', () => { - expect(controllerContent).toContain("substring(0, 16) + '...'"); - }); -}); diff --git a/services/mana-core-auth/src/security/security.module.ts b/services/mana-core-auth/src/security/security.module.ts deleted file mode 100644 index c4c22e524..000000000 --- a/services/mana-core-auth/src/security/security.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SecurityEventsService } from './security-events.service'; -import { AccountLockoutService } from './account-lockout.service'; -import { LoggerModule } from '../common/logger'; - -@Module({ - imports: [LoggerModule], - providers: [SecurityEventsService, AccountLockoutService], - exports: [SecurityEventsService, AccountLockoutService], -}) -export class SecurityModule {} diff --git a/services/mana-core-auth/src/stripe/index.ts b/services/mana-core-auth/src/stripe/index.ts deleted file mode 100644 index 71f3586ab..000000000 --- a/services/mana-core-auth/src/stripe/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './stripe.module'; -export * from './stripe.service'; diff --git a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts deleted file mode 100644 index a469d8b49..000000000 --- a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - Controller, - Post, - Req, - Headers, - HttpCode, - BadRequestException, - Logger, - Inject, - forwardRef, -} from '@nestjs/common'; -import { ApiTags, ApiExcludeEndpoint } from '@nestjs/swagger'; -import type { Request } from 'express'; -import type Stripe from 'stripe'; -import { StripeService } from './stripe.service'; -import { SubscriptionsService } from '../subscriptions/subscriptions.service'; - -interface RawBodyRequest extends Request { - rawBody?: Buffer; -} - -/** - * Stripe Webhook Controller — Subscription events only. - * - * Credit-related events (payment_intent.*, checkout.session.*) are handled - * by the standalone mana-credits service. - */ -@ApiTags('webhooks') -@Controller('webhooks/stripe') -export class StripeWebhookController { - private readonly logger = new Logger(StripeWebhookController.name); - - constructor( - private stripeService: StripeService, - @Inject(forwardRef(() => SubscriptionsService)) - private subscriptionsService: SubscriptionsService - ) {} - - @Post() - @HttpCode(200) - @ApiExcludeEndpoint() - async handleWebhook(@Req() req: RawBodyRequest, @Headers('stripe-signature') signature: string) { - const rawBody = req.rawBody; - if (!rawBody) throw new BadRequestException('Missing raw body'); - if (!signature) throw new BadRequestException('Missing stripe-signature header'); - - let event: Stripe.Event; - try { - event = this.stripeService.verifyWebhookSignature(rawBody, signature); - } catch (err) { - this.logger.warn('Webhook signature verification failed', { - error: err instanceof Error ? err.message : 'Unknown error', - }); - throw new BadRequestException('Invalid webhook signature'); - } - - this.logger.log('Webhook received', { type: event.type, id: event.id }); - - switch (event.type) { - // Subscriptions - case 'customer.subscription.created': - case 'customer.subscription.updated': - case 'customer.subscription.deleted': - await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription); - break; - - // Invoices - case 'invoice.created': - case 'invoice.updated': - case 'invoice.paid': - case 'invoice.payment_failed': - await this.handleInvoiceUpdated(event.data.object as Stripe.Invoice); - break; - - default: - this.logger.debug(`Unhandled event type: ${event.type}`); - } - - return { received: true }; - } - - private async handleSubscriptionUpdated(subscription: Stripe.Subscription) { - this.logger.log('Processing subscription update', { - subscriptionId: subscription.id, - status: subscription.status, - }); - - try { - await this.subscriptionsService.handleSubscriptionUpdated(subscription); - } catch (error) { - this.logger.error('Failed to process subscription update', { - subscriptionId: subscription.id, - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw error; - } - } - - private async handleInvoiceUpdated(invoice: Stripe.Invoice) { - this.logger.log('Processing invoice update', { - invoiceId: invoice.id, - status: invoice.status, - }); - - try { - await this.subscriptionsService.handleInvoiceUpdated(invoice); - } catch (error) { - this.logger.error('Failed to process invoice update', { - invoiceId: invoice.id, - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw error; - } - } -} diff --git a/services/mana-core-auth/src/stripe/stripe.module.ts b/services/mana-core-auth/src/stripe/stripe.module.ts deleted file mode 100644 index 8bb89eaa2..000000000 --- a/services/mana-core-auth/src/stripe/stripe.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { StripeService } from './stripe.service'; -import { StripeWebhookController } from './stripe-webhook.controller'; -import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; - -@Module({ - imports: [forwardRef(() => SubscriptionsModule)], - controllers: [StripeWebhookController], - providers: [StripeService], - exports: [StripeService], -}) -export class StripeModule {} diff --git a/services/mana-core-auth/src/stripe/stripe.service.ts b/services/mana-core-auth/src/stripe/stripe.service.ts deleted file mode 100644 index db2dda74f..000000000 --- a/services/mana-core-auth/src/stripe/stripe.service.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Stripe from 'stripe'; -import { eq } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { stripeCustomers } from '../db/schema'; - -export interface PaymentIntentMetadata { - userId: string; - packageId: string; - purchaseId: string; -} - -export interface CheckoutSessionMetadata { - userId: string; - packageId: string; - purchaseId: string; - roomId?: string; -} - -export interface CheckoutSessionOptions { - customerId: string; - amountCents: number; - productName: string; - credits: number; - metadata: CheckoutSessionMetadata; - successUrl: string; - cancelUrl: string; -} - -@Injectable() -export class StripeService { - private stripe: Stripe | null = null; - private readonly logger = new Logger(StripeService.name); - - constructor(private configService: ConfigService) { - const secretKey = this.configService.get('stripe.secretKey'); - if (secretKey) { - this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' }); - this.logger.log('Stripe client initialized'); - } else { - this.logger.warn('Stripe secret key not configured - payment features disabled'); - } - } - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - private ensureStripeConfigured(): Stripe { - if (!this.stripe) { - throw new ServiceUnavailableException('Stripe is not configured'); - } - return this.stripe; - } - - /** - * Get or create a Stripe customer for a user - * Caches the customer ID in the stripe_customers table - */ - async getOrCreateCustomer(userId: string, email: string): Promise { - const stripe = this.ensureStripeConfigured(); - const db = this.getDb(); - - // Check if we already have a Stripe customer for this user - const [existing] = await db - .select() - .from(stripeCustomers) - .where(eq(stripeCustomers.userId, userId)) - .limit(1); - - if (existing) { - return existing.stripeCustomerId; - } - - // Create a new Stripe customer - try { - const customer = await stripe.customers.create({ - email, - metadata: { userId }, - }); - - // Store the mapping - await db.insert(stripeCustomers).values({ - userId, - stripeCustomerId: customer.id, - email, - }); - - this.logger.log('Created Stripe customer', { userId, customerId: customer.id }); - return customer.id; - } catch (error) { - this.logger.error('Failed to create Stripe customer', { - userId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw new ServiceUnavailableException('Failed to create payment customer'); - } - } - - /** - * Create a PaymentIntent for a credit package purchase - */ - async createPaymentIntent( - customerId: string, - amountCents: number, - metadata: PaymentIntentMetadata - ): Promise { - const stripe = this.ensureStripeConfigured(); - - try { - const paymentIntent = await stripe.paymentIntents.create({ - amount: amountCents, - currency: 'eur', - customer: customerId, - metadata: { - userId: metadata.userId, - packageId: metadata.packageId, - purchaseId: metadata.purchaseId, - }, - automatic_payment_methods: { - enabled: true, - }, - }); - - this.logger.log('Created PaymentIntent', { - paymentIntentId: paymentIntent.id, - amount: amountCents, - customerId, - }); - - return paymentIntent; - } catch (error) { - this.logger.error('Failed to create PaymentIntent', { - customerId, - amount: amountCents, - error: error instanceof Error ? error.message : 'Unknown error', - }); - - if (error instanceof Stripe.errors.StripeError) { - throw new ServiceUnavailableException(`Payment service error: ${error.message}`); - } - throw new ServiceUnavailableException('Failed to create payment intent'); - } - } - - /** - * Verify a Stripe webhook signature and parse the event - */ - verifyWebhookSignature(payload: Buffer, signature: string): Stripe.Event { - const stripe = this.ensureStripeConfigured(); - const webhookSecret = this.configService.get('stripe.webhookSecret'); - - if (!webhookSecret) { - throw new ServiceUnavailableException('Stripe webhook secret not configured'); - } - - try { - return stripe.webhooks.constructEvent(payload, signature, webhookSecret); - } catch (error) { - this.logger.warn('Webhook signature verification failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw error; - } - } - - /** - * Retrieve a PaymentIntent by ID (for verification) - */ - async retrievePaymentIntent(paymentIntentId: string): Promise { - const stripe = this.ensureStripeConfigured(); - return stripe.paymentIntents.retrieve(paymentIntentId); - } - - /** - * Create a Checkout Session for credit purchase - * Returns a URL where the user can complete payment - */ - async createCheckoutSession(options: CheckoutSessionOptions): Promise { - const stripe = this.ensureStripeConfigured(); - - try { - const session = await stripe.checkout.sessions.create({ - customer: options.customerId, - mode: 'payment', - payment_method_types: ['card', 'sepa_debit'], - line_items: [ - { - price_data: { - currency: 'eur', - product_data: { - name: options.productName, - description: `${options.credits} Credits`, - }, - unit_amount: options.amountCents, - }, - quantity: 1, - }, - ], - metadata: { - userId: options.metadata.userId, - packageId: options.metadata.packageId, - purchaseId: options.metadata.purchaseId, - roomId: options.metadata.roomId || '', - }, - success_url: options.successUrl, - cancel_url: options.cancelUrl, - expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60, // 24 hours - }); - - this.logger.log('Created Checkout Session', { - sessionId: session.id, - amount: options.amountCents, - customerId: options.customerId, - purchaseId: options.metadata.purchaseId, - }); - - return session; - } catch (error) { - this.logger.error('Failed to create Checkout Session', { - customerId: options.customerId, - amount: options.amountCents, - error: error instanceof Error ? error.message : 'Unknown error', - }); - - if (error instanceof Stripe.errors.StripeError) { - throw new ServiceUnavailableException(`Payment service error: ${error.message}`); - } - throw new ServiceUnavailableException('Failed to create checkout session'); - } - } - - /** - * Retrieve a Checkout Session by ID - */ - async retrieveCheckoutSession(sessionId: string): Promise { - const stripe = this.ensureStripeConfigured(); - return stripe.checkout.sessions.retrieve(sessionId); - } -} diff --git a/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts b/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts deleted file mode 100644 index e272b8307..000000000 --- a/services/mana-core-auth/src/subscriptions/dto/create-checkout-session.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsUUID, IsEnum, IsUrl } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class CreateCheckoutSessionDto { - @ApiProperty({ description: 'Plan ID to subscribe to' }) - @IsUUID() - planId: string; - - @ApiProperty({ enum: ['month', 'year'], default: 'month' }) - @IsEnum(['month', 'year']) - billingInterval: 'month' | 'year' = 'month'; - - @ApiProperty({ description: 'URL to redirect to after successful payment' }) - @IsUrl() - successUrl: string; - - @ApiProperty({ description: 'URL to redirect to if payment is canceled' }) - @IsUrl() - cancelUrl: string; -} diff --git a/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts b/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts deleted file mode 100644 index 7fd4bf2fd..000000000 --- a/services/mana-core-auth/src/subscriptions/dto/create-portal-session.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsUrl } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class CreatePortalSessionDto { - @ApiProperty({ description: 'URL to return to after leaving the portal' }) - @IsUrl() - returnUrl: string; -} diff --git a/services/mana-core-auth/src/subscriptions/index.ts b/services/mana-core-auth/src/subscriptions/index.ts deleted file mode 100644 index de0a4aae6..000000000 --- a/services/mana-core-auth/src/subscriptions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './subscriptions.module'; -export * from './subscriptions.service'; diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts b/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts deleted file mode 100644 index cd8f90804..000000000 --- a/services/mana-core-auth/src/subscriptions/subscriptions.controller.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Controller, Get, Post, Body, Param, UseGuards, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { SubscriptionsService } from './subscriptions.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { CreateCheckoutSessionDto } from './dto/create-checkout-session.dto'; -import { CreatePortalSessionDto } from './dto/create-portal-session.dto'; - -@ApiTags('subscriptions') -@Controller('subscriptions') -export class SubscriptionsController { - constructor(private readonly subscriptionsService: SubscriptionsService) {} - - // ============================================================================ - // PUBLIC ENDPOINTS - // ============================================================================ - - @Get('plans') - @ApiOperation({ summary: 'Get all available subscription plans' }) - @ApiResponse({ status: 200, description: 'Returns list of active plans' }) - async getPlans() { - return this.subscriptionsService.getPlans(); - } - - @Get('plans/:planId') - @ApiOperation({ summary: 'Get a specific plan' }) - @ApiResponse({ status: 200, description: 'Returns plan details' }) - @ApiResponse({ status: 404, description: 'Plan not found' }) - async getPlan(@Param('planId') planId: string) { - return this.subscriptionsService.getPlan(planId); - } - - // ============================================================================ - // PROTECTED ENDPOINTS - // ============================================================================ - - @Get('current') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get current subscription' }) - @ApiResponse({ status: 200, description: 'Returns current subscription and plan' }) - async getCurrentSubscription(@CurrentUser() user: CurrentUserData) { - return this.subscriptionsService.getCurrentSubscription(user.userId); - } - - @Post('checkout') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create Stripe checkout session for subscription' }) - @ApiResponse({ status: 201, description: 'Returns checkout session URL' }) - async createCheckoutSession( - @CurrentUser() user: CurrentUserData, - @Body() dto: CreateCheckoutSessionDto - ) { - return this.subscriptionsService.createCheckoutSession( - user.userId, - dto.planId, - dto.billingInterval, - dto.successUrl, - dto.cancelUrl - ); - } - - @Post('portal') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Create Stripe Customer Portal session' }) - @ApiResponse({ status: 201, description: 'Returns portal URL for billing management' }) - async createPortalSession( - @CurrentUser() user: CurrentUserData, - @Body() dto: CreatePortalSessionDto - ) { - return this.subscriptionsService.createPortalSession(user.userId, dto.returnUrl); - } - - @Post('cancel') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Cancel subscription at period end' }) - @ApiResponse({ status: 200, description: 'Subscription scheduled for cancellation' }) - async cancelSubscription(@CurrentUser() user: CurrentUserData) { - return this.subscriptionsService.cancelSubscription(user.userId); - } - - @Post('reactivate') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Reactivate a canceled subscription' }) - @ApiResponse({ status: 200, description: 'Subscription reactivated' }) - async reactivateSubscription(@CurrentUser() user: CurrentUserData) { - return this.subscriptionsService.reactivateSubscription(user.userId); - } - - @Get('invoices') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Get invoice history' }) - @ApiResponse({ status: 200, description: 'Returns list of invoices' }) - async getInvoices(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - return this.subscriptionsService.getInvoices(user.userId, limit); - } -} diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.module.ts b/services/mana-core-auth/src/subscriptions/subscriptions.module.ts deleted file mode 100644 index 03b46ace4..000000000 --- a/services/mana-core-auth/src/subscriptions/subscriptions.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { SubscriptionsController } from './subscriptions.controller'; -import { SubscriptionsService } from './subscriptions.service'; -import { StripeModule } from '../stripe/stripe.module'; - -@Module({ - imports: [forwardRef(() => StripeModule)], - controllers: [SubscriptionsController], - providers: [SubscriptionsService], - exports: [SubscriptionsService], -}) -export class SubscriptionsModule {} diff --git a/services/mana-core-auth/src/subscriptions/subscriptions.service.ts b/services/mana-core-auth/src/subscriptions/subscriptions.service.ts deleted file mode 100644 index 41520bbac..000000000 --- a/services/mana-core-auth/src/subscriptions/subscriptions.service.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, - Inject, - forwardRef, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, desc } from 'drizzle-orm'; -import Stripe from 'stripe'; -import { getDb } from '../db/connection'; -import { plans, subscriptions, invoices, users, stripeCustomers } from '../db/schema'; -import { StripeService } from '../stripe/stripe.service'; - -@Injectable() -export class SubscriptionsService { - private readonly logger = new Logger(SubscriptionsService.name); - private stripe: Stripe | null = null; - - constructor( - private configService: ConfigService, - @Inject(forwardRef(() => StripeService)) - private stripeService: StripeService - ) { - const secretKey = this.configService.get('stripe.secretKey'); - if (secretKey) { - this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' }); - } - } - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - // ============================================================================ - // PLANS - // ============================================================================ - - /** - * Get all active plans - */ - async getPlans() { - const db = this.getDb(); - return db.select().from(plans).where(eq(plans.active, true)).orderBy(plans.sortOrder); - } - - /** - * Get a specific plan by ID - */ - async getPlan(planId: string) { - const db = this.getDb(); - const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1); - if (!plan) { - throw new NotFoundException('Plan not found'); - } - return plan; - } - - // ============================================================================ - // SUBSCRIPTIONS - // ============================================================================ - - /** - * Get user's current subscription - */ - async getCurrentSubscription(userId: string) { - const db = this.getDb(); - - const [subscription] = await db - .select({ - subscription: subscriptions, - plan: plans, - }) - .from(subscriptions) - .innerJoin(plans, eq(subscriptions.planId, plans.id)) - .where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active'))) - .limit(1); - - if (!subscription) { - // Return default free plan info - const [freePlan] = await db.select().from(plans).where(eq(plans.isDefault, true)).limit(1); - - return { - plan: freePlan || null, - subscription: null, - isFreePlan: true, - }; - } - - return { - plan: subscription.plan, - subscription: subscription.subscription, - isFreePlan: false, - }; - } - - /** - * Create a Stripe Checkout Session for subscription - */ - async createCheckoutSession( - userId: string, - planId: string, - billingInterval: 'month' | 'year' = 'month', - successUrl: string, - cancelUrl: string - ) { - if (!this.stripe) { - throw new BadRequestException('Stripe is not configured'); - } - - const db = this.getDb(); - - // Get plan - const [plan] = await db.select().from(plans).where(eq(plans.id, planId)).limit(1); - if (!plan) { - throw new NotFoundException('Plan not found'); - } - - // Get Stripe price ID - const stripePriceId = - billingInterval === 'year' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly; - - if (!stripePriceId) { - throw new BadRequestException(`No Stripe price configured for ${billingInterval} billing`); - } - - // Get user - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - if (!user) { - throw new NotFoundException('User not found'); - } - - // Get or create Stripe customer - const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email); - - // Create checkout session - const session = await this.stripe.checkout.sessions.create({ - customer: stripeCustomerId, - mode: 'subscription', - payment_method_types: ['card', 'sepa_debit'], - line_items: [ - { - price: stripePriceId, - quantity: 1, - }, - ], - success_url: successUrl, - cancel_url: cancelUrl, - metadata: { - userId, - planId, - billingInterval, - }, - subscription_data: { - metadata: { - userId, - planId, - }, - }, - }); - - this.logger.log('Checkout session created', { - sessionId: session.id, - userId, - planId, - }); - - return { - sessionId: session.id, - url: session.url, - }; - } - - /** - * Create a Stripe Customer Portal session for self-service billing - */ - async createPortalSession(userId: string, returnUrl: string) { - if (!this.stripe) { - throw new BadRequestException('Stripe is not configured'); - } - - const db = this.getDb(); - - // Get Stripe customer ID - const [customer] = await db - .select() - .from(stripeCustomers) - .where(eq(stripeCustomers.userId, userId)) - .limit(1); - - if (!customer) { - throw new BadRequestException('No billing account found. Please subscribe to a plan first.'); - } - - const session = await this.stripe.billingPortal.sessions.create({ - customer: customer.stripeCustomerId, - return_url: returnUrl, - }); - - this.logger.log('Portal session created', { userId }); - - return { - url: session.url, - }; - } - - /** - * Cancel subscription (at period end) - */ - async cancelSubscription(userId: string) { - if (!this.stripe) { - throw new BadRequestException('Stripe is not configured'); - } - - const db = this.getDb(); - - const [subscription] = await db - .select() - .from(subscriptions) - .where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active'))) - .limit(1); - - if (!subscription || !subscription.stripeSubscriptionId) { - throw new NotFoundException('No active subscription found'); - } - - // Cancel at period end (user keeps access until end of billing period) - await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, { - cancel_at_period_end: true, - }); - - // Update local record - await db - .update(subscriptions) - .set({ - cancelAtPeriodEnd: true, - updatedAt: new Date(), - }) - .where(eq(subscriptions.id, subscription.id)); - - this.logger.log('Subscription scheduled for cancellation', { - userId, - subscriptionId: subscription.id, - }); - - return { success: true, cancelAtPeriodEnd: true }; - } - - /** - * Reactivate a canceled subscription (if still within billing period) - */ - async reactivateSubscription(userId: string) { - if (!this.stripe) { - throw new BadRequestException('Stripe is not configured'); - } - - const db = this.getDb(); - - const [subscription] = await db - .select() - .from(subscriptions) - .where(and(eq(subscriptions.userId, userId), eq(subscriptions.cancelAtPeriodEnd, true))) - .limit(1); - - if (!subscription || !subscription.stripeSubscriptionId) { - throw new NotFoundException('No canceled subscription found'); - } - - // Remove cancellation - await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, { - cancel_at_period_end: false, - }); - - await db - .update(subscriptions) - .set({ - cancelAtPeriodEnd: false, - canceledAt: null, - updatedAt: new Date(), - }) - .where(eq(subscriptions.id, subscription.id)); - - this.logger.log('Subscription reactivated', { userId }); - - return { success: true }; - } - - // ============================================================================ - // INVOICES - // ============================================================================ - - /** - * Get user's invoices - */ - async getInvoices(userId: string, limit = 20) { - const db = this.getDb(); - - return db - .select() - .from(invoices) - .where(eq(invoices.userId, userId)) - .orderBy(desc(invoices.createdAt)) - .limit(limit); - } - - // ============================================================================ - // WEBHOOK HANDLERS - // ============================================================================ - - /** - * Handle Stripe subscription created/updated - */ - async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) { - const db = this.getDb(); - const userId = stripeSubscription.metadata?.userId; - - if (!userId) { - this.logger.warn('Subscription webhook missing userId in metadata', { - subscriptionId: stripeSubscription.id, - }); - return; - } - - const planId = stripeSubscription.metadata?.planId; - - // Check if subscription exists - const [existing] = await db - .select() - .from(subscriptions) - .where(eq(subscriptions.stripeSubscriptionId, stripeSubscription.id)) - .limit(1); - - const subscriptionData = { - userId, - planId: planId || existing?.planId, - stripeSubscriptionId: stripeSubscription.id, - stripeCustomerId: stripeSubscription.customer as string, - stripePriceId: stripeSubscription.items.data[0]?.price.id, - status: stripeSubscription.status as any, - billingInterval: stripeSubscription.items.data[0]?.price.recurring?.interval as any, - currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000), - currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), - cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, - canceledAt: stripeSubscription.canceled_at - ? new Date(stripeSubscription.canceled_at * 1000) - : null, - endedAt: stripeSubscription.ended_at ? new Date(stripeSubscription.ended_at * 1000) : null, - trialStart: stripeSubscription.trial_start - ? new Date(stripeSubscription.trial_start * 1000) - : null, - trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null, - updatedAt: new Date(), - }; - - if (existing) { - await db.update(subscriptions).set(subscriptionData).where(eq(subscriptions.id, existing.id)); - - this.logger.log('Subscription updated', { - subscriptionId: existing.id, - status: stripeSubscription.status, - }); - } else { - const [created] = await db - .insert(subscriptions) - .values(subscriptionData as any) - .returning(); - - this.logger.log('Subscription created', { - subscriptionId: created.id, - userId, - }); - } - } - - /** - * Handle Stripe invoice events - */ - async handleInvoiceUpdated(stripeInvoice: Stripe.Invoice) { - const db = this.getDb(); - - // Get user from customer - const [customer] = await db - .select() - .from(stripeCustomers) - .where(eq(stripeCustomers.stripeCustomerId, stripeInvoice.customer as string)) - .limit(1); - - if (!customer) { - this.logger.warn('Invoice webhook: customer not found', { - stripeCustomerId: stripeInvoice.customer, - }); - return; - } - - // Get subscription if exists - let subscriptionId: string | null = null; - if (stripeInvoice.subscription) { - const [sub] = await db - .select() - .from(subscriptions) - .where(eq(subscriptions.stripeSubscriptionId, stripeInvoice.subscription as string)) - .limit(1); - subscriptionId = sub?.id || null; - } - - const invoiceData = { - userId: customer.userId, - subscriptionId, - stripeInvoiceId: stripeInvoice.id, - stripeCustomerId: stripeInvoice.customer as string, - number: stripeInvoice.number, - status: stripeInvoice.status || 'unknown', - amountDueEuroCents: stripeInvoice.amount_due, - amountPaidEuroCents: stripeInvoice.amount_paid, - currency: stripeInvoice.currency, - hostedInvoiceUrl: stripeInvoice.hosted_invoice_url, - invoicePdfUrl: stripeInvoice.invoice_pdf, - periodStart: stripeInvoice.period_start ? new Date(stripeInvoice.period_start * 1000) : null, - periodEnd: stripeInvoice.period_end ? new Date(stripeInvoice.period_end * 1000) : null, - dueDate: stripeInvoice.due_date ? new Date(stripeInvoice.due_date * 1000) : null, - paidAt: - stripeInvoice.status === 'paid' && stripeInvoice.status_transitions?.paid_at - ? new Date(stripeInvoice.status_transitions.paid_at * 1000) - : null, - }; - - // Upsert invoice - const [existing] = await db - .select() - .from(invoices) - .where(eq(invoices.stripeInvoiceId, stripeInvoice.id)) - .limit(1); - - if (existing) { - await db.update(invoices).set(invoiceData).where(eq(invoices.id, existing.id)); - } else { - await db.insert(invoices).values(invoiceData as any); - } - - this.logger.log('Invoice synced', { - invoiceId: stripeInvoice.id, - status: stripeInvoice.status, - }); - } -} diff --git a/services/mana-core-auth/test/__mocks__/better-auth-adapters.ts b/services/mana-core-auth/test/__mocks__/better-auth-adapters.ts deleted file mode 100644 index da1184cf0..000000000 --- a/services/mana-core-auth/test/__mocks__/better-auth-adapters.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Mock implementation of better-auth adapters for tests - */ - -// Mock Drizzle adapter -export const drizzleAdapter = jest.fn((db: unknown, config?: Record) => ({ - id: 'drizzle', - name: 'Drizzle Adapter', - db, - config, -})); - -// Export all adapters -export default { - drizzleAdapter, -}; diff --git a/services/mana-core-auth/test/__mocks__/better-auth-plugins.ts b/services/mana-core-auth/test/__mocks__/better-auth-plugins.ts deleted file mode 100644 index 4fa50c9a7..000000000 --- a/services/mana-core-auth/test/__mocks__/better-auth-plugins.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Mock implementation of better-auth plugins for tests - */ - -// Mock JWT plugin -export const jwt = jest.fn((config?: Record) => ({ - id: 'jwt', - name: 'JWT Plugin', - config, -})); - -// Mock Organization plugin -export const organization = jest.fn((config?: Record) => ({ - id: 'organization', - name: 'Organization Plugin', - config, - // Default roles - organizationRole: config?.organizationRole || { - owner: { permissions: ['all'] }, - admin: { permissions: ['invite', 'manage_members'] }, - member: { permissions: ['view'] }, - }, -})); - -// Mock types for organization plugin -export interface Organization { - id: string; - name: string; - slug: string; - logo?: string | null; - metadata?: Record; - createdAt: Date; -} - -export interface Member { - id: string; - organizationId: string; - userId: string; - role: string; - createdAt: Date; -} - -export interface Invitation { - id: string; - organizationId: string; - email: string; - role: string; - status: string; - expiresAt: Date; - inviterId: string; - createdAt: Date; -} - -export type OrganizationRole = 'owner' | 'admin' | 'member'; -export type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'canceled'; - -// Export all plugins -export default { - jwt, - organization, -}; diff --git a/services/mana-core-auth/test/__mocks__/better-auth.ts b/services/mana-core-auth/test/__mocks__/better-auth.ts deleted file mode 100644 index 881e3b2a1..000000000 --- a/services/mana-core-auth/test/__mocks__/better-auth.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Mock implementation of better-auth for tests - * This mock allows tests to run without requiring actual Better Auth dependencies - */ - -// Mock user type -interface MockUser { - id: string; - email: string; - name?: string; - role?: string; - createdAt?: Date; -} - -// Mock session type -interface MockSession { - token: string; - expiresAt: Date; - userId: string; - activeOrganizationId?: string; - metadata?: Record; -} - -// Mock organization type -interface MockOrganization { - id: string; - name: string; - slug: string; - logo?: string; - metadata?: Record; - createdAt?: Date; -} - -// Mock member type -interface MockMember { - id: string; - organizationId: string; - userId: string; - role: 'owner' | 'admin' | 'member'; - createdAt?: Date; -} - -// Mock invitation type -interface MockInvitation { - id: string; - organizationId: string; - email: string; - role: string; - status: 'pending' | 'accepted' | 'rejected' | 'canceled'; - expiresAt: Date; - inviterId: string; - createdAt?: Date; -} - -// Mock API responses -const createMockApi = () => ({ - // Auth endpoints - signUpEmail: jest.fn().mockResolvedValue({ - data: { - user: { - id: 'mock-user-id', - email: 'mock@example.com', - name: 'Mock User', - role: 'user', - createdAt: new Date(), - }, - session: { - token: 'mock-session-token', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - }, - }), - - signInEmail: jest.fn().mockResolvedValue({ - data: { - user: { - id: 'mock-user-id', - email: 'mock@example.com', - name: 'Mock User', - role: 'user', - }, - session: { - token: 'mock-session-token', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - }, - }), - - signOut: jest.fn().mockResolvedValue({ success: true }), - - // Organization endpoints - createOrganization: jest.fn().mockResolvedValue({ - data: { - id: 'mock-org-id', - name: 'Mock Organization', - slug: 'mock-organization', - createdAt: new Date(), - }, - }), - - listOrganizations: jest.fn().mockResolvedValue({ - data: [], - }), - - inviteMember: jest.fn().mockResolvedValue({ - data: { - id: 'mock-invitation-id', - email: 'invitee@example.com', - role: 'member', - status: 'pending', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }, - }), - - acceptInvitation: jest.fn().mockResolvedValue({ - data: { - id: 'mock-member-id', - organizationId: 'mock-org-id', - userId: 'mock-user-id', - role: 'member', - }, - }), - - listOrganizationMembers: jest.fn().mockResolvedValue({ - data: [], - }), - - removeMember: jest.fn().mockResolvedValue({ success: true }), - - setActiveOrganization: jest.fn().mockResolvedValue({ - data: { - session: { - activeOrganizationId: 'mock-org-id', - }, - }, - }), - - getActiveOrganization: jest.fn().mockResolvedValue({ - data: null, - }), -}); - -// Mock auth instance -export const betterAuth = jest.fn(() => ({ - api: createMockApi(), - handler: jest.fn(), -})); - -// Export mock types for tests -export type { MockUser, MockSession, MockOrganization, MockMember, MockInvitation }; - -// Export types matching better-auth/types exports -export interface User { - id: string; - email: string; - name: string | null; - emailVerified: boolean; - image?: string | null; - createdAt: Date; - updatedAt: Date; -} - -export interface Session { - id: string; - userId: string; - token: string; - expiresAt: Date; - createdAt: Date; - updatedAt: Date; - ipAddress?: string | null; - userAgent?: string | null; -} - -// Default export -export default { betterAuth }; diff --git a/services/mana-core-auth/test/__mocks__/jose.ts b/services/mana-core-auth/test/__mocks__/jose.ts deleted file mode 100644 index 1dbca3ecf..000000000 --- a/services/mana-core-auth/test/__mocks__/jose.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Jose Mock - Implements basic JWT functions for testing - * - * Since jose is an ESM module and jest runs in CommonJS mode, - * we need to provide a compatible mock implementation. - * - * This mock uses Node.js crypto for HS256 signing/verification. - */ - -import * as crypto from 'crypto'; - -// Error classes matching jose's error types -export const errors = { - JWTExpired: class JWTExpired extends Error { - constructor(message = 'jwt expired') { - super(message); - this.name = 'JWTExpired'; - } - }, - 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; - private header: { alg?: string; typ?: string } = { typ: 'JWT' }; - - constructor(payload: Record) { - 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 { - 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; protectedHeader: Record }> { - 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; - 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; - 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 { - 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 { - throw new Error('exportJWK not supported in mock'); -} - -// importJWK mock -export async function importJWK(_jwk: unknown, _alg?: string): Promise { - throw new Error('importJWK not supported in mock'); -} - -// decodeJwt helper -export function decodeJwt(token: string): Record { - 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 { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new errors.JWSInvalid('Invalid Compact JWS'); - } - return JSON.parse(base64urlDecode(parts[0]).toString('utf8')); -} diff --git a/services/mana-core-auth/test/__mocks__/nanoid.ts b/services/mana-core-auth/test/__mocks__/nanoid.ts deleted file mode 100644 index d3d644567..000000000 --- a/services/mana-core-auth/test/__mocks__/nanoid.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Mock implementation of nanoid for tests - */ - -let counter = 0; - -export const nanoid = (size?: number): string => { - counter++; - const id = `test-id-${counter}`; - if (size && size < id.length) { - return id.substring(0, size); - } - return id; -}; - -export const customAlphabet = (alphabet: string, size: number) => { - return () => nanoid(size); -}; diff --git a/services/mana-core-auth/test/e2e/auth-flow.e2e-spec.ts b/services/mana-core-auth/test/e2e/auth-flow.e2e-spec.ts deleted file mode 100644 index 6e4f700cb..000000000 --- a/services/mana-core-auth/test/e2e/auth-flow.e2e-spec.ts +++ /dev/null @@ -1,608 +0,0 @@ -/** - * Authentication Flow E2E Tests - * - * Focused tests for core authentication flows: - * 1. Registration flow - * 2. Login flow - * 3. Token refresh flow - * 4. Logout flow - * 5. Session management - * 6. Token validation - * - * These tests complement the comprehensive B2C/B2B journey tests - * by providing focused coverage of authentication primitives. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../src/app.module'; - -describe('Authentication Flow (E2E)', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Registration Flow', () => { - it('should register a new user successfully', async () => { - const uniqueEmail = `auth-flow-${Date.now()}@example.com`; - - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Auth Flow User', - }) - .expect(201); - - expect(response.body).toMatchObject({ - id: expect.any(String), - email: uniqueEmail, - name: 'Auth Flow User', - }); - }); - - it('should reject duplicate email registration', async () => { - const uniqueEmail = `auth-dup-${Date.now()}@example.com`; - - // First registration should succeed - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'First User', - }) - .expect(201); - - // Second registration with same email should fail - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'DifferentPassword123!', - name: 'Second User', - }) - .expect((res) => { - expect([400, 409]).toContain(res.status); - }); - }); - - it('should reject registration with invalid email', async () => { - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: 'not-an-email', - password: 'SecurePassword123!', - name: 'Invalid Email User', - }) - .expect(400); - }); - - it('should reject registration with short password', async () => { - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: `short-pwd-${Date.now()}@example.com`, - password: '123', - name: 'Short Password User', - }) - .expect(400); - }); - - it('should allow registration without name', async () => { - const uniqueEmail = `no-name-${Date.now()}@example.com`; - - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - }) - .expect(201); - - expect(response.body.email).toBe(uniqueEmail); - }); - }); - - describe('Login Flow', () => { - const loginTestEmail = `login-flow-${Date.now()}@example.com`; - const loginTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - // Create user for login tests - await request(app.getHttpServer()).post('/auth/register').send({ - email: loginTestEmail, - password: loginTestPassword, - name: 'Login Test User', - }); - }); - - it('should login with valid credentials', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: loginTestEmail, - password: loginTestPassword, - }) - .expect(200); - - expect(response.body).toMatchObject({ - user: { - email: loginTestEmail, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - tokenType: 'Bearer', - expiresIn: expect.any(Number), - }); - }); - - it('should reject login with wrong password', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: loginTestEmail, - password: 'WrongPassword123!', - }) - .expect(401); - - expect(response.body.message).toBe('Invalid credentials'); - }); - - it('should reject login with non-existent email', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'nonexistent@example.com', - password: 'SomePassword123!', - }) - .expect(401); - - expect(response.body.message).toBe('Invalid credentials'); - }); - - it('should accept optional device info', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: loginTestEmail, - password: loginTestPassword, - deviceId: 'device-123', - deviceName: 'Test Device', - }) - .expect(200); - - expect(response.body.accessToken).toBeDefined(); - }); - }); - - describe('Token Refresh Flow', () => { - let accessToken: string; - let refreshToken: string; - const refreshTestEmail = `refresh-${Date.now()}@example.com`; - const refreshTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - // Create and login user - await request(app.getHttpServer()).post('/auth/register').send({ - email: refreshTestEmail, - password: refreshTestPassword, - name: 'Refresh Test User', - }); - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: refreshTestEmail, - password: refreshTestPassword, - }); - - accessToken = loginResponse.body.accessToken; - refreshToken = loginResponse.body.refreshToken; - }); - - it('should refresh tokens with valid refresh token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/refresh') - .send({ - refreshToken, - }) - .expect(200); - - expect(response.body).toMatchObject({ - user: { - email: refreshTestEmail, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - }); - - // New tokens should be different from old ones - expect(response.body.accessToken).not.toBe(accessToken); - expect(response.body.refreshToken).not.toBe(refreshToken); - }); - - it('should reject refresh with invalid token', async () => { - await request(app.getHttpServer()) - .post('/auth/refresh') - .send({ - refreshToken: 'invalid-refresh-token', - }) - .expect(401); - }); - - it('should reject refresh with empty token', async () => { - await request(app.getHttpServer()) - .post('/auth/refresh') - .send({ - refreshToken: '', - }) - .expect((res) => { - expect([400, 401]).toContain(res.status); - }); - }); - }); - - describe('Session Flow', () => { - let accessToken: string; - let refreshToken: string; - const sessionTestEmail = `session-${Date.now()}@example.com`; - const sessionTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - await request(app.getHttpServer()).post('/auth/register').send({ - email: sessionTestEmail, - password: sessionTestPassword, - name: 'Session Test User', - }); - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: sessionTestEmail, - password: sessionTestPassword, - }); - - accessToken = loginResponse.body.accessToken; - refreshToken = loginResponse.body.refreshToken; - }); - - it('should get session with valid token', async () => { - const response = await request(app.getHttpServer()) - .get('/auth/session') - .set('Authorization', `Bearer ${accessToken}`) - .expect((res) => { - expect([200, 401]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('user'); - expect(response.body.user.email).toBe(sessionTestEmail); - } - }); - - it('should reject session request without token', async () => { - await request(app.getHttpServer()).get('/auth/session').expect(401); - }); - - it('should reject session request with invalid token', async () => { - await request(app.getHttpServer()) - .get('/auth/session') - .set('Authorization', 'Bearer invalid-token') - .expect(401); - }); - }); - - describe('Logout Flow', () => { - let accessToken: string; - let refreshToken: string; - const logoutTestEmail = `logout-${Date.now()}@example.com`; - const logoutTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - await request(app.getHttpServer()).post('/auth/register').send({ - email: logoutTestEmail, - password: logoutTestPassword, - name: 'Logout Test User', - }); - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: logoutTestEmail, - password: logoutTestPassword, - }); - - accessToken = loginResponse.body.accessToken; - refreshToken = loginResponse.body.refreshToken; - }); - - it('should logout successfully', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/logout') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - message: 'Logged out successfully', - }); - }); - - it('should invalidate token after logout', async () => { - // First logout - await request(app.getHttpServer()) - .post('/auth/logout') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - // Try to access protected endpoint - await request(app.getHttpServer()) - .get('/auth/session') - .set('Authorization', `Bearer ${accessToken}`) - .expect(401); - }); - - it('should reject logout without token', async () => { - await request(app.getHttpServer()).post('/auth/logout').expect(401); - }); - }); - - describe('Token Validation', () => { - let accessToken: string; - const validateTestEmail = `validate-${Date.now()}@example.com`; - const validateTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - await request(app.getHttpServer()).post('/auth/register').send({ - email: validateTestEmail, - password: validateTestPassword, - name: 'Validate Test User', - }); - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: validateTestEmail, - password: validateTestPassword, - }); - - accessToken = loginResponse.body.accessToken; - }); - - it('should validate valid token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/validate') - .send({ token: accessToken }) - .expect(200); - - expect(response.body).toHaveProperty('valid', true); - expect(response.body).toHaveProperty('payload'); - expect(response.body.payload).toHaveProperty('sub'); - expect(response.body.payload).toHaveProperty('email', validateTestEmail); - }); - - it('should reject invalid token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/validate') - .send({ token: 'invalid-jwt-token' }) - .expect(200); - - expect(response.body).toHaveProperty('valid', false); - }); - - it('should reject malformed token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/validate') - .send({ token: 'not.a.valid.jwt' }) - .expect(200); - - expect(response.body).toHaveProperty('valid', false); - }); - }); - - describe('JWKS Endpoint', () => { - it('should return JWKS from /auth/jwks', async () => { - const response = await request(app.getHttpServer()) - .get('/auth/jwks') - .expect((res) => { - expect([200, 500]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('keys'); - expect(Array.isArray(response.body.keys)).toBe(true); - } - }); - }); - - describe('Password Reset Flow', () => { - const resetTestEmail = `reset-${Date.now()}@example.com`; - const resetTestPassword = 'SecurePassword123!'; - - beforeAll(async () => { - await request(app.getHttpServer()).post('/auth/register').send({ - email: resetTestEmail, - password: resetTestPassword, - name: 'Reset Test User', - }); - }); - - it('should accept password reset request', async () => { - // This should always return success to prevent email enumeration - const response = await request(app.getHttpServer()) - .post('/auth/forgot-password') - .send({ - email: resetTestEmail, - }) - .expect(200); - - expect(response.body).toMatchObject({ - message: expect.any(String), - }); - }); - - it('should accept reset request for non-existent email', async () => { - // Should not reveal if email exists - const response = await request(app.getHttpServer()) - .post('/auth/forgot-password') - .send({ - email: 'nonexistent@example.com', - }) - .expect(200); - - expect(response.body).toMatchObject({ - message: expect.any(String), - }); - }); - - it('should reject reset with invalid token', async () => { - await request(app.getHttpServer()) - .post('/auth/reset-password') - .send({ - token: 'invalid-reset-token', - newPassword: 'NewSecurePassword123!', - }) - .expect((res) => { - expect([400, 401]).toContain(res.status); - }); - }); - }); - - describe('Email Verification Flow', () => { - const verifyTestEmail = `verify-${Date.now()}@example.com`; - - beforeAll(async () => { - await request(app.getHttpServer()).post('/auth/register').send({ - email: verifyTestEmail, - password: 'SecurePassword123!', - name: 'Verify Test User', - }); - }); - - it('should accept resend verification request', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/resend-verification') - .send({ - email: verifyTestEmail, - }) - .expect(200); - - expect(response.body).toMatchObject({ - message: expect.any(String), - }); - }); - - it('should accept resend for non-existent email', async () => { - // Should not reveal if email exists - const response = await request(app.getHttpServer()) - .post('/auth/resend-verification') - .send({ - email: 'nonexistent@example.com', - }) - .expect(200); - - expect(response.body).toMatchObject({ - message: expect.any(String), - }); - }); - }); -}); - -describe('Rate Limiting (E2E)', () => { - let app: INestApplication; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('should rate limit registration endpoint', async () => { - const requests = []; - const timestamp = Date.now(); - - // Make more than the limit (5 req/min) - for (let i = 0; i < 10; i++) { - requests.push( - request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: `rate-limit-${timestamp}-${i}@example.com`, - password: 'SecurePassword123!', - name: 'Rate Limit User', - }) - ); - } - - const responses = await Promise.all(requests); - - // Some should be rate limited (429) - const rateLimited = responses.some((r) => r.status === 429); - if (rateLimited) { - expect(rateLimited).toBe(true); - } - }); - - it('should rate limit login endpoint', async () => { - const requests = []; - const timestamp = Date.now(); - - // Make more than the limit (10 req/min) - for (let i = 0; i < 15; i++) { - requests.push( - request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: `rate-limit-login-${timestamp}@example.com`, - password: 'WrongPassword123!', - }) - ); - } - - const responses = await Promise.all(requests); - - // Some should be rate limited (429) - const rateLimited = responses.some((r) => r.status === 429); - if (rateLimited) { - expect(rateLimited).toBe(true); - } - }); - - it('should rate limit forgot-password endpoint', async () => { - const requests = []; - - // Make more than the limit (3 req/min) - for (let i = 0; i < 10; i++) { - requests.push( - request(app.getHttpServer()) - .post('/auth/forgot-password') - .send({ - email: `rate-limit-forgot-${i}@example.com`, - }) - ); - } - - const responses = await Promise.all(requests); - - // Some should be rate limited (429) - const rateLimited = responses.some((r) => r.status === 429); - if (rateLimited) { - expect(rateLimited).toBe(true); - } - }); -}); diff --git a/services/mana-core-auth/test/e2e/b2c-journey.e2e-spec.ts b/services/mana-core-auth/test/e2e/b2c-journey.e2e-spec.ts deleted file mode 100644 index ad5dbf55f..000000000 --- a/services/mana-core-auth/test/e2e/b2c-journey.e2e-spec.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * B2C User Journey E2E Tests - * - * Complete end-to-end test for B2C user lifecycle: - * 1. Register account - * 2. Login and get tokens - * 3. Use credits for various apps - * 4. Check balance and history - * 5. Refresh token - * 6. Logout - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../src/app.module'; - -describe('B2C User Journey (E2E)', () => { - let app: INestApplication; - let accessToken: string; - let refreshToken: string; - let userId: string; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Complete B2C Journey', () => { - const uniqueEmail = `b2c-e2e-${Date.now()}@example.com`; - const password = 'SecurePassword123!'; - - it('Step 1: Register new B2C user', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password, - name: 'B2C E2E User', - }) - .expect(201); - - expect(response.body).toMatchObject({ - id: expect.any(String), - email: uniqueEmail, - name: 'B2C E2E User', - }); - - userId = response.body.id; - }); - - it('Step 2: Login and receive JWT tokens', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: uniqueEmail, - password, - }) - .expect(200); - - expect(response.body).toMatchObject({ - user: { - id: userId, - email: uniqueEmail, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - tokenType: 'Bearer', - expiresIn: 900, - }); - - accessToken = response.body.accessToken; - refreshToken = response.body.refreshToken; - }); - - it('Step 3: Get initial credit balance', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - balance: 0, - freeCreditsRemaining: 150, // Signup bonus - dailyFreeCredits: 5, - totalSpent: 0, - }); - }); - - it('Step 4: Use credits for audio transcription (Memoro)', async () => { - const response = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 25, - appId: 'memoro', - description: 'Audio transcription', - metadata: { - fileId: 'audio-123', - duration: 120, - }, - }) - .expect(200); - - expect(response.body).toMatchObject({ - success: true, - newBalance: { - balance: 0, - freeCreditsRemaining: 125, // 150 - 25 - totalSpent: 25, - }, - }); - }); - - it('Step 5: Use credits for image generation (Picture)', async () => { - const response = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 30, - appId: 'picture', - description: 'AI image generation', - metadata: { - prompt: 'Beautiful sunset', - model: 'dall-e-3', - }, - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.newBalance.freeCreditsRemaining).toBe(95); // 125 - 30 - }); - - it('Step 6: Use credits for chat conversation', async () => { - const response = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 15, - appId: 'chat', - description: 'AI chat conversation', - }) - .expect(200); - - expect(response.body.newBalance.freeCreditsRemaining).toBe(80); // 95 - 15 - expect(response.body.newBalance.totalSpent).toBe(70); // 25 + 30 + 15 - }); - - it('Step 7: Check updated balance', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - balance: 0, - freeCreditsRemaining: 80, - totalSpent: 70, - }); - }); - - it('Step 8: Get transaction history', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/transactions') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - expect(response.body.length).toBeGreaterThanOrEqual(4); // signup + 3 usage - - // Verify transactions are in descending order - const transactions = response.body; - expect(transactions[0].appId).toBe('chat'); // Most recent - }); - - it('Step 9: Refresh access token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/refresh') - .send({ - refreshToken, - }) - .expect(200); - - expect(response.body).toMatchObject({ - user: { - id: userId, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - }); - - // Update tokens - const newAccessToken = response.body.accessToken; - const newRefreshToken = response.body.refreshToken; - - expect(newAccessToken).not.toBe(accessToken); - expect(newRefreshToken).not.toBe(refreshToken); - - accessToken = newAccessToken; - refreshToken = newRefreshToken; - }); - - it('Step 10: Verify new access token works', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(response.body.freeCreditsRemaining).toBe(80); - }); - - it('Step 11: Attempt to use more credits than available', async () => { - await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 200, // More than available - appId: 'wisekeep', - description: 'Video analysis', - }) - .expect(400); - }); - - it('Step 12: Test idempotency with duplicate request', async () => { - const idempotencyKey = `idempotent-${Date.now()}`; - - // First request - const response1 = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 5, - appId: 'test', - description: 'Idempotency test', - idempotencyKey, - }) - .expect(200); - - const balanceAfterFirst = response1.body.newBalance.freeCreditsRemaining; - - // Second request with same idempotency key - const response2 = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - amount: 5, - appId: 'test', - description: 'Idempotency test', - idempotencyKey, - }) - .expect(200); - - expect(response2.body.message).toBe('Transaction already processed'); - - // Verify balance unchanged - const balanceCheck = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(balanceCheck.body.freeCreditsRemaining).toBe(balanceAfterFirst); - }); - - it('Step 13: Get credit packages', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/packages') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - - if (response.body.length > 0) { - expect(response.body[0]).toMatchObject({ - id: expect.any(String), - name: expect.any(String), - credits: expect.any(Number), - priceEuroCents: expect.any(Number), - }); - } - }); - - it('Step 14: Logout and revoke session', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/logout') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - message: 'Logged out successfully', - }); - }); - - it('Step 15: Verify access token no longer works after logout', async () => { - await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${accessToken}`) - .expect(401); - }); - - it('Step 16: Verify refresh token no longer works after logout', async () => { - await request(app.getHttpServer()) - .post('/auth/refresh') - .send({ - refreshToken, - }) - .expect(401); - }); - }); - - describe('Edge Cases and Error Handling', () => { - it('should reject registration with invalid email', async () => { - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: 'invalid-email', - password: 'SecurePassword123!', - name: 'Test User', - }) - .expect(400); - }); - - it('should reject registration with weak password', async () => { - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: `test-weak-${Date.now()}@example.com`, - password: '123', // Too weak - name: 'Test User', - }) - .expect(400); - }); - - it('should reject credit usage without authentication', async () => { - await request(app.getHttpServer()) - .post('/credits/use') - .send({ - amount: 10, - appId: 'test', - description: 'Unauthorized attempt', - }) - .expect(401); - }); - - it('should reject credit usage with invalid token', async () => { - await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', 'Bearer invalid-token-12345') - .send({ - amount: 10, - appId: 'test', - description: 'Invalid token attempt', - }) - .expect(401); - }); - - it('should reject negative credit amounts', async () => { - // First, register and login - const uniqueEmail = `negative-test-${Date.now()}@example.com`; - const registerResponse = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Negative Test', - }) - .expect(201); - - const loginResponse = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - }) - .expect(200); - - const token = loginResponse.body.accessToken; - - // Attempt to use negative credits - await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${token}`) - .send({ - amount: -10, // Negative amount - appId: 'test', - description: 'Negative credits', - }) - .expect(400); - }); - - it('should handle concurrent requests safely', async () => { - const uniqueEmail = `concurrent-e2e-${Date.now()}@example.com`; - - // Register and login - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Concurrent User', - }) - .expect(201); - - const loginResponse = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: uniqueEmail, - password: 'SecurePassword123!', - }) - .expect(200); - - const token = loginResponse.body.accessToken; - - // Send multiple concurrent requests - const requests = []; - for (let i = 0; i < 5; i++) { - requests.push( - request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${token}`) - .send({ - amount: 5, - appId: 'test', - description: `Concurrent request ${i}`, - }) - ); - } - - const responses = await Promise.all(requests); - - // All should succeed - responses.forEach((response) => { - expect([200, 409]).toContain(response.status); // 200 success or 409 conflict - }); - }); - }); - - describe('Security Tests', () => { - it('should not expose sensitive data in error messages', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: 'nonexistent@example.com', - password: 'SomePassword123!', - }) - .expect(401); - - // Error should not reveal whether user exists - expect(response.body.message).toBe('Invalid credentials'); - expect(response.body).not.toHaveProperty('userId'); - }); - - it('should enforce rate limiting on login attempts', async () => { - // Note: This test assumes rate limiting is configured - // Make multiple failed login attempts - - const promises = []; - for (let i = 0; i < 20; i++) { - promises.push( - request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: `brute-force-${Date.now()}@example.com`, - password: 'wrong-password', - }) - ); - } - - const responses = await Promise.all(promises); - - // Eventually should get rate limited (429) - const rateLimited = responses.some((r) => r.status === 429); - - // If rate limiting is implemented, this should be true - // If not implemented yet, this test will fail (which is good feedback) - if (rateLimited) { - expect(rateLimited).toBe(true); - } - }); - - it('should reject SQL injection attempts in email field', async () => { - const sqlInjectionPayloads = ["admin'--", "' OR '1'='1", "'; DROP TABLE users; --"]; - - for (const payload of sqlInjectionPayloads) { - const response = await request(app.getHttpServer()).post('/auth/login').send({ - email: payload, - password: 'SomePassword123!', - }); - - // Should fail safely without SQL injection - expect([400, 401]).toContain(response.status); - } - }); - }); -}); diff --git a/services/mana-core-auth/test/e2e/guild-journey.e2e-spec.ts b/services/mana-core-auth/test/e2e/guild-journey.e2e-spec.ts deleted file mode 100644 index bb0c87adf..000000000 --- a/services/mana-core-auth/test/e2e/guild-journey.e2e-spec.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * Guild (Gilde) Journey E2E Tests - * - * Complete end-to-end test for Guild workflows: - * 1. Create guild with pool - * 2. Invite and onboard members - * 3. Fund guild pool from personal balance - * 4. Members use credits from pool - * 5. Spending limits enforcement - * 6. Credit source routing (personal vs guild) - * 7. Member removal and access control - * 8. Edge cases (concurrent, idempotency, insufficient funds) - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../src/app.module'; - -describe('Guild Journey (E2E)', () => { - let app: INestApplication; - let gildenmeisterToken: string; - let memberToken: string; - let gildenmesterId: string; - let memberId: string; - let guildId: string; - - const uniqueTimestamp = Date.now(); - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - // ========================================================================= - // Phase 1: Guild Creation - // ========================================================================= - - describe('Phase 1: Guild Creation', () => { - const ownerEmail = `gildenmeister-${uniqueTimestamp}@test.com`; - const ownerPassword = 'SecurePassword123!'; - - it('should register the Gildenmeister', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: ownerEmail, - password: ownerPassword, - name: 'Gildenmeister Max', - }) - .expect(201); - - gildenmesterId = response.body.id; - }); - - it('should login as Gildenmeister', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: ownerEmail, - password: ownerPassword, - }) - .expect(200); - - gildenmeisterToken = response.body.accessToken; - expect(gildenmeisterToken).toBeDefined(); - }); - - it('should create a guild via POST /gilden', async () => { - const response = await request(app.getHttpServer()) - .post('/gilden') - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ - name: `Testgilde ${uniqueTimestamp}`, - }) - .expect(201); - - expect(response.body.gilde).toBeDefined(); - expect(response.body.gilde.name).toBe(`Testgilde ${uniqueTimestamp}`); - expect(response.body.pool).toBeDefined(); - expect(response.body.pool.balance).toBe(0); - - guildId = response.body.gilde.id; - }); - - it('should show guild pool balance of 0', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/balance`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - balance: 0, - totalFunded: 0, - totalSpent: 0, - }); - }); - - it('should list the guild in user guilds', async () => { - const response = await request(app.getHttpServer()) - .get('/gilden') - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body.guilds).toBeDefined(); - expect(response.body.guilds.length).toBeGreaterThanOrEqual(1); - - const guild = response.body.guilds.find((g: any) => g.gilde.id === guildId); - expect(guild).toBeDefined(); - }); - }); - - // ========================================================================= - // Phase 2: Member Management - // ========================================================================= - - describe('Phase 2: Member Management', () => { - const memberEmail = `gildenmitglied-${uniqueTimestamp}@test.com`; - const memberPassword = 'SecurePassword123!'; - - it('should register a potential member', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: memberEmail, - password: memberPassword, - name: 'Mitglied Anna', - }) - .expect(201); - - memberId = response.body.id; - }); - - it('should login as member', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: memberEmail, - password: memberPassword, - }) - .expect(200); - - memberToken = response.body.accessToken; - }); - - it('should not allow non-member to access guild pool', async () => { - await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/balance`) - .set('Authorization', `Bearer ${memberToken}`) - .expect(403); - }); - - it('should invite member to guild', async () => { - const response = await request(app.getHttpServer()) - .post(`/gilden/${guildId}/invite`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ - email: memberEmail, - role: 'member', - }) - .expect(201); - - expect(response.body).toBeDefined(); - }); - - it('should list pending invitations for member', async () => { - const response = await request(app.getHttpServer()) - .get('/auth/invitations') - .set('Authorization', `Bearer ${memberToken}`) - .expect(200); - - expect(response.body.length).toBeGreaterThanOrEqual(1); - - const invitation = response.body.find((inv: any) => inv.organizationId === guildId); - expect(invitation).toBeDefined(); - - // Accept the invitation - await request(app.getHttpServer()) - .post('/gilden/accept-invitation') - .set('Authorization', `Bearer ${memberToken}`) - .send({ - invitationId: invitation.id, - }) - .expect(201); - }); - - it('should now allow member to access guild pool', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/balance`) - .set('Authorization', `Bearer ${memberToken}`) - .expect(200); - - expect(response.body.balance).toBe(0); - }); - }); - - // ========================================================================= - // Phase 3: Pool Funding - // ========================================================================= - - describe('Phase 3: Pool Funding', () => { - it('should give Gildenmeister some personal credits (simulated)', async () => { - // Purchase credits to personal balance first - // We simulate this by directly adding credits via the use endpoint workaround - // In a real scenario, this would be a Stripe purchase - const { ConfigService } = await import('@nestjs/config'); - const configService = app.get(ConfigService); - const databaseUrl = configService.get('database.url'); - const { getDb } = await import('../../src/db/connection'); - const { balances } = await import('../../src/db/schema'); - const { eq } = await import('drizzle-orm'); - const db = getDb(databaseUrl!); - - await db - .update(balances) - .set({ - balance: 5000, - totalEarned: 5000, - }) - .where(eq(balances.userId, gildenmesterId)); - - // Verify - const response = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body.balance).toBe(5000); - }); - - it('should fund guild pool from personal balance', async () => { - const response = await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/fund`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ - amount: 2000, - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.personalBalance.balance).toBe(3000); // 5000 - 2000 - expect(response.body.poolBalance.balance).toBe(2000); - }); - - it('should verify guild pool balance increased', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/balance`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body).toMatchObject({ - balance: 2000, - totalFunded: 2000, - totalSpent: 0, - }); - }); - - it('should verify personal balance decreased', async () => { - const response = await request(app.getHttpServer()) - .get('/credits/balance') - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body.balance).toBe(3000); - }); - - it('should prevent member from funding (not owner/admin)', async () => { - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/fund`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ amount: 100 }) - .expect(403); - }); - - it('should prevent funding more than personal balance', async () => { - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/fund`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ amount: 10000 }) - .expect(400); - }); - }); - - // ========================================================================= - // Phase 4: Credit Usage from Pool - // ========================================================================= - - describe('Phase 4: Credit Usage from Pool', () => { - it('should allow member to use credits from guild pool', async () => { - const response = await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 50, - appId: 'chat', - description: 'AI chat conversation', - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.newBalance.balance).toBe(1950); // 2000 - 50 - expect(response.body.transaction.userId).toBe(memberId); - expect(response.body.transaction.organizationId).toBe(guildId); - }); - - it('should allow Gildenmeister to use credits from pool too', async () => { - const response = await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ - amount: 100, - appId: 'picture', - description: 'Image generation', - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.newBalance.balance).toBe(1850); // 1950 - 100 - }); - - it('should track guild transactions', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/transactions`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - // Owner should see all transactions (funding + 2 usages) - expect(response.body.length).toBeGreaterThanOrEqual(3); - - const usageTransactions = response.body.filter((t: any) => t.type === 'usage'); - expect(usageTransactions.length).toBe(2); - }); - - it('should only show own transactions to members', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/transactions`) - .set('Authorization', `Bearer ${memberToken}`) - .expect(200); - - // Member should only see their own transactions - response.body.forEach((t: any) => { - expect(t.userId).toBe(memberId); - }); - }); - }); - - // ========================================================================= - // Phase 5: Credit Source Routing - // ========================================================================= - - describe('Phase 5: Credit Source Routing', () => { - it('should route to guild pool via POST /credits/use with creditSource', async () => { - const response = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 25, - appId: 'todo', - description: 'Task creation', - creditSource: { - type: 'guild', - guildId, - }, - }) - .expect(200); - - expect(response.body.success).toBe(true); - expect(response.body.newBalance.balance).toBe(1825); // 1850 - 25 - }); - - it('should use personal balance without creditSource', async () => { - // Give member some personal credits first - const { ConfigService } = await import('@nestjs/config'); - const configService = app.get(ConfigService); - const databaseUrl = configService.get('database.url'); - const { getDb } = await import('../../src/db/connection'); - const { balances } = await import('../../src/db/schema'); - const { eq } = await import('drizzle-orm'); - const db = getDb(databaseUrl!); - - await db - .update(balances) - .set({ balance: 100, totalEarned: 100 }) - .where(eq(balances.userId, memberId)); - - const response = await request(app.getHttpServer()) - .post('/credits/use') - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 10, - appId: 'todo', - description: 'Personal task', - }) - .expect(200); - - expect(response.body.success).toBe(true); - // Should have deducted from personal balance, not guild - expect(response.body.newBalance.balance).toBe(90); // 100 - 10 - }); - }); - - // ========================================================================= - // Phase 6: Spending Limits - // ========================================================================= - - describe('Phase 6: Spending Limits', () => { - it('should set daily spending limit for member', async () => { - const response = await request(app.getHttpServer()) - .put(`/credits/guild/${guildId}/members/${memberId}/limits`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ - dailyLimit: 100, - monthlyLimit: 500, - }) - .expect(200); - - expect(response.body.dailyLimit).toBe(100); - expect(response.body.monthlyLimit).toBe(500); - }); - - it('should get member spending limits', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/members/${memberId}/limits`) - .set('Authorization', `Bearer ${memberToken}`) - .expect(200); - - expect(response.body.dailyLimit).toBe(100); - expect(response.body.monthlyLimit).toBe(500); - }); - - it('should get member spending summary', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/members/${memberId}/spending`) - .set('Authorization', `Bearer ${memberToken}`) - .expect(200); - - expect(response.body.dailyLimit).toBe(100); - expect(response.body.monthlyLimit).toBe(500); - expect(response.body.spentToday).toBeGreaterThanOrEqual(0); - expect(response.body.dailyRemaining).toBeDefined(); - }); - - it('should enforce daily spending limit', async () => { - // Member already spent 75 today (50 + 25 from guild pool) - // Daily limit is 100, so spending 50 more should fail - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 50, - appId: 'chat', - description: 'Should exceed daily limit', - }) - .expect(400); - }); - - it('should prevent member from setting their own limits', async () => { - await request(app.getHttpServer()) - .put(`/credits/guild/${guildId}/members/${memberId}/limits`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ dailyLimit: 99999 }) - .expect(403); - }); - }); - - // ========================================================================= - // Phase 7: Edge Cases & Security - // ========================================================================= - - describe('Phase 7: Edge Cases & Security', () => { - it('should prevent using more credits than pool has', async () => { - // Remove limit first so we can test pool balance - await request(app.getHttpServer()) - .put(`/credits/guild/${guildId}/members/${memberId}/limits`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ dailyLimit: null, monthlyLimit: null }) - .expect(200); - - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 999999, - appId: 'chat', - description: 'Way too much', - }) - .expect(400); - }); - - it('should support idempotent guild credit usage', async () => { - const idempotencyKey = `guild-idem-${Date.now()}`; - - const response1 = await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 10, - appId: 'test', - description: 'Idempotency test', - idempotencyKey, - }) - .expect(200); - - // Second request with same key - const response2 = await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 10, - appId: 'test', - description: 'Idempotency test', - idempotencyKey, - }) - .expect(200); - - expect(response2.body.message).toBe('Transaction already processed'); - }); - - it('should prevent non-member from using guild credits', async () => { - // Register a random user not in the guild - const randomEmail = `random-${Date.now()}@test.com`; - - await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: randomEmail, - password: 'SecurePassword123!', - name: 'Random User', - }) - .expect(201); - - const loginRes = await request(app.getHttpServer()) - .post('/auth/login') - .send({ email: randomEmail, password: 'SecurePassword123!' }) - .expect(200); - - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${loginRes.body.accessToken}`) - .send({ - amount: 10, - appId: 'chat', - description: 'Unauthorized', - }) - .expect(403); - }); - - it('should prevent funding with negative amount', async () => { - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/fund`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .send({ amount: -100 }) - .expect(400); - }); - - it('should require authentication for all guild endpoints', async () => { - await request(app.getHttpServer()).get(`/credits/guild/${guildId}/balance`).expect(401); - - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/fund`) - .send({ amount: 100 }) - .expect(401); - - await request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .send({ amount: 10, appId: 'chat', description: 'test' }) - .expect(401); - }); - - it('should handle concurrent guild spending safely', async () => { - const requests = []; - for (let i = 0; i < 3; i++) { - requests.push( - request(app.getHttpServer()) - .post(`/credits/guild/${guildId}/use`) - .set('Authorization', `Bearer ${memberToken}`) - .send({ - amount: 5, - appId: 'test', - description: `Concurrent ${i}`, - }) - ); - } - - const responses = await Promise.all(requests); - - // All should either succeed or conflict - responses.forEach((response) => { - expect([200, 409]).toContain(response.status); - }); - }); - }); - - // ========================================================================= - // Phase 8: Final State Verification - // ========================================================================= - - describe('Phase 8: Final State', () => { - it('should show accurate final guild pool balance', async () => { - const response = await request(app.getHttpServer()) - .get(`/credits/guild/${guildId}/balance`) - .set('Authorization', `Bearer ${gildenmeisterToken}`) - .expect(200); - - expect(response.body.balance).toBeGreaterThanOrEqual(0); - expect(response.body.totalFunded).toBe(2000); - expect(response.body.totalSpent).toBeGreaterThan(0); - - console.log('\n=== Guild Journey Summary ==='); - console.log('Pool Balance:', response.body); - console.log('===========================\n'); - }); - }); -}); diff --git a/services/mana-core-auth/test/e2e/oidc.e2e-spec.ts b/services/mana-core-auth/test/e2e/oidc.e2e-spec.ts deleted file mode 100644 index a5ecf60a2..000000000 --- a/services/mana-core-auth/test/e2e/oidc.e2e-spec.ts +++ /dev/null @@ -1,644 +0,0 @@ -/** - * OIDC Provider E2E Tests - * - * Tests for OpenID Connect provider functionality: - * 1. OIDC Discovery endpoint - * 2. JWKS endpoint - * 3. Authorization endpoint - * 4. Token endpoint - * 5. UserInfo endpoint - * - * These tests verify that mana-core-auth can act as an OIDC Provider - * for external services like Matrix/Synapse. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../src/app.module'; -import { ConfigService } from '@nestjs/config'; -import { getDb } from '../../src/db/connection'; -import { oauthApplications } from '../../src/db/schema'; -import { eq } from 'drizzle-orm'; -import { randomBytes, createHash } from 'crypto'; - -// Helper to generate random IDs -const generateId = (length = 16): string => { - return randomBytes(Math.ceil(length / 2)) - .toString('hex') - .slice(0, length); -}; - -// Helper to generate PKCE code verifier and challenge -const generatePKCE = () => { - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - return { verifier, challenge }; -}; - -describe('OIDC Provider (E2E)', () => { - let app: INestApplication; - let configService: ConfigService; - let testClientId: string; - let testClientSecret: string; - let testRedirectUri: string; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - configService = app.get(ConfigService); - await app.init(); - - // Create test OIDC client - testClientId = `test-client-${generateId(8)}`; - testClientSecret = randomBytes(32).toString('hex'); - testRedirectUri = 'https://test.example.com/callback'; - - const databaseUrl = configService.get('database.url'); - if (databaseUrl) { - const db = getDb(databaseUrl); - await db.insert(oauthApplications).values({ - id: generateId(16), - name: 'Test OIDC Client', - clientId: testClientId, - clientSecret: testClientSecret, - redirectUrls: testRedirectUri, - type: 'web', - disabled: false, - metadata: JSON.stringify({ - description: 'E2E test client', - trusted: true, - }), - createdAt: new Date(), - updatedAt: new Date(), - }); - } - }); - - afterAll(async () => { - // Clean up test client - const databaseUrl = configService.get('database.url'); - if (databaseUrl) { - const db = getDb(databaseUrl); - await db.delete(oauthApplications).where(eq(oauthApplications.clientId, testClientId)); - } - await app.close(); - }); - - describe('OIDC Discovery', () => { - it('should return OIDC discovery document at /.well-known/openid-configuration', async () => { - const response = await request(app.getHttpServer()) - .get('/.well-known/openid-configuration') - .expect((res) => { - // Accept 200 OK or 500 if Better Auth is mocked - expect([200, 500]).toContain(res.status); - }); - - if (response.status === 200) { - const discovery = response.body; - - // Required OIDC Discovery fields - expect(discovery).toHaveProperty('issuer'); - expect(discovery).toHaveProperty('authorization_endpoint'); - expect(discovery).toHaveProperty('token_endpoint'); - expect(discovery).toHaveProperty('jwks_uri'); - - // Recommended fields - expect(discovery).toHaveProperty('response_types_supported'); - expect(discovery).toHaveProperty('subject_types_supported'); - expect(discovery).toHaveProperty('id_token_signing_alg_values_supported'); - - // Verify endpoints are correct format - expect(discovery.issuer).toMatch(/^https?:\/\//); - expect(discovery.authorization_endpoint).toMatch(/authorize/); - expect(discovery.token_endpoint).toMatch(/token/); - } - }); - }); - - describe('JWKS Endpoint', () => { - it('should return JWKS at /api/auth/jwks', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/jwks') - .expect((res) => { - expect([200, 500]).toContain(res.status); - }); - - if (response.status === 200) { - const jwks = response.body; - - // JWKS must have keys array - expect(jwks).toHaveProperty('keys'); - expect(Array.isArray(jwks.keys)).toBe(true); - - if (jwks.keys.length > 0) { - const key = jwks.keys[0]; - // JWK required fields - expect(key).toHaveProperty('kty'); - expect(key).toHaveProperty('use', 'sig'); - expect(key).toHaveProperty('kid'); - } - } - }); - - it('should return JWKS at alternative path /api/oidc/jwks', async () => { - const response = await request(app.getHttpServer()) - .get('/api/oidc/jwks') - .expect((res) => { - expect([200, 500]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('keys'); - } - }); - - it('should return JWKS at /auth/jwks via auth controller', async () => { - const response = await request(app.getHttpServer()) - .get('/auth/jwks') - .expect((res) => { - expect([200, 500]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('keys'); - } - }); - }); - - describe('Authorization Endpoint', () => { - it('should handle authorization request with required parameters', async () => { - const state = generateId(16); - - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: testClientId, - redirect_uri: testRedirectUri, - response_type: 'code', - scope: 'openid profile email', - state, - }) - .expect((res) => { - // Should redirect to login or return error for unauthenticated user - expect([200, 302, 400, 401, 500]).toContain(res.status); - }); - - if (response.status === 302) { - const location = response.headers.location; - // Should redirect to login page or back with error - expect(location).toBeDefined(); - } - }); - - it('should reject authorization request with missing client_id', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - redirect_uri: testRedirectUri, - response_type: 'code', - scope: 'openid', - }) - .expect((res) => { - expect([400, 500]).toContain(res.status); - }); - - if (response.status === 400) { - expect(response.body).toHaveProperty('error'); - } - }); - - it('should reject authorization request with invalid client_id', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: 'non-existent-client', - redirect_uri: 'https://attacker.com/callback', - response_type: 'code', - scope: 'openid', - }) - .expect((res) => { - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should handle PKCE authorization request', async () => { - const { verifier, challenge } = generatePKCE(); - const state = generateId(16); - - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: testClientId, - redirect_uri: testRedirectUri, - response_type: 'code', - scope: 'openid profile email', - state, - code_challenge: challenge, - code_challenge_method: 'S256', - }) - .expect((res) => { - expect([200, 302, 400, 401, 500]).toContain(res.status); - }); - }); - - it('should work with alternative path /api/oidc/authorize', async () => { - const state = generateId(16); - - const response = await request(app.getHttpServer()) - .get('/api/oidc/authorize') - .query({ - client_id: testClientId, - redirect_uri: testRedirectUri, - response_type: 'code', - scope: 'openid', - state, - }) - .expect((res) => { - expect([200, 302, 400, 401, 500]).toContain(res.status); - }); - }); - }); - - describe('Token Endpoint', () => { - it('should reject token request without credentials', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .send({ - grant_type: 'authorization_code', - code: 'invalid-code', - redirect_uri: testRedirectUri, - }) - .expect((res) => { - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should handle token request with form-urlencoded body', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'authorization_code', - code: 'test-code', - redirect_uri: testRedirectUri, - client_id: testClientId, - client_secret: testClientSecret, - }).toString() - ) - .expect((res) => { - // Invalid code should fail - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should handle token request with Basic auth', async () => { - const credentials = Buffer.from(`${testClientId}:${testClientSecret}`).toString('base64'); - - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Authorization', `Basic ${credentials}`) - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'authorization_code', - code: 'test-code', - redirect_uri: testRedirectUri, - }).toString() - ) - .expect((res) => { - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should reject refresh_token grant with invalid token', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: 'invalid-refresh-token', - client_id: testClientId, - client_secret: testClientSecret, - }).toString() - ) - .expect((res) => { - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should work with alternative path /api/oidc/token', async () => { - const response = await request(app.getHttpServer()) - .post('/api/oidc/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'authorization_code', - code: 'test-code', - redirect_uri: testRedirectUri, - client_id: testClientId, - client_secret: testClientSecret, - }).toString() - ) - .expect((res) => { - expect([400, 401, 500]).toContain(res.status); - }); - }); - }); - - describe('UserInfo Endpoint', () => { - it('should reject userinfo request without token', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/userinfo') - .expect((res) => { - expect([401, 500]).toContain(res.status); - }); - }); - - it('should reject userinfo request with invalid token', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/userinfo') - .set('Authorization', 'Bearer invalid-token-12345') - .expect((res) => { - expect([401, 500]).toContain(res.status); - }); - }); - - it('should work with alternative path /api/oidc/userinfo', async () => { - const response = await request(app.getHttpServer()) - .get('/api/oidc/userinfo') - .expect((res) => { - expect([401, 500]).toContain(res.status); - }); - }); - }); - - describe('Complete OIDC Authorization Code Flow', () => { - let userAccessToken: string; - let authorizationCode: string; - const testEmail = `oidc-flow-${Date.now()}@example.com`; - const testPassword = 'SecurePassword123!'; - - it('Step 1: Register a test user', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/register') - .send({ - email: testEmail, - password: testPassword, - name: 'OIDC Test User', - }) - .expect((res) => { - expect([201, 400]).toContain(res.status); - }); - - if (response.status === 201) { - expect(response.body).toHaveProperty('id'); - } - }); - - it('Step 2: Login to get user token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: testEmail, - password: testPassword, - }) - .expect((res) => { - expect([200, 401]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('accessToken'); - userAccessToken = response.body.accessToken; - } - }); - - it('Step 3: Initiate authorization with session', async () => { - // Note: In a real E2E test, we would need to: - // 1. Have the user authenticate via the login page - // 2. Set session cookie - // 3. Then make the authorize request - // Since we use mocked Better Auth, this flow is simulated - - const state = generateId(16); - const nonce = generateId(16); - - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: testClientId, - redirect_uri: testRedirectUri, - response_type: 'code', - scope: 'openid profile email', - state, - nonce, - }) - .expect((res) => { - expect([200, 302, 400, 401, 500]).toContain(res.status); - }); - - // In a real flow, this would redirect with an authorization code - if (response.status === 302 && response.headers.location) { - const locationUrl = new URL(response.headers.location, 'https://test.example.com'); - authorizationCode = locationUrl.searchParams.get('code') || ''; - } - }); - - it('Step 4: Exchange code for tokens (mocked)', async () => { - // Skip if no authorization code was obtained - if (!authorizationCode) { - console.log( - 'Skipping token exchange - no authorization code obtained (expected with mocked Better Auth)' - ); - return; - } - - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'authorization_code', - code: authorizationCode, - redirect_uri: testRedirectUri, - client_id: testClientId, - client_secret: testClientSecret, - }).toString() - ) - .expect((res) => { - expect([200, 400, 401]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('access_token'); - expect(response.body).toHaveProperty('token_type', 'Bearer'); - expect(response.body).toHaveProperty('id_token'); - } - }); - }); - - describe('Security Tests', () => { - it('should reject redirect_uri mismatch', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: testClientId, - redirect_uri: 'https://attacker.com/steal-code', - response_type: 'code', - scope: 'openid', - }) - .expect((res) => { - // Should fail due to redirect_uri mismatch - expect([400, 401, 500]).toContain(res.status); - }); - }); - - it('should reject unsupported response_type', async () => { - const response = await request(app.getHttpServer()) - .get('/api/auth/oauth2/authorize') - .query({ - client_id: testClientId, - redirect_uri: testRedirectUri, - response_type: 'token', // Implicit flow - may not be supported - scope: 'openid', - }) - .expect((res) => { - // May fail or succeed depending on configuration - expect([200, 302, 400, 500]).toContain(res.status); - }); - }); - - it('should reject client_credentials grant for confidential client', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'client_credentials', - client_id: testClientId, - client_secret: testClientSecret, - scope: 'openid', - }).toString() - ) - .expect((res) => { - // client_credentials may not be supported - expect([200, 400, 401, 500]).toContain(res.status); - }); - }); - - it('should not leak error details', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/oauth2/token') - .set('Content-Type', 'application/x-www-form-urlencoded') - .send( - new URLSearchParams({ - grant_type: 'authorization_code', - code: 'definitely-invalid-code', - redirect_uri: testRedirectUri, - client_id: testClientId, - client_secret: 'wrong-secret', - }).toString() - ); - - // Error response should not contain sensitive info - if (response.body.error_description) { - expect(response.body.error_description).not.toContain('database'); - expect(response.body.error_description).not.toContain('sql'); - expect(response.body.error_description).not.toContain('stack'); - } - }); - }); -}); - -describe('OIDC Integration with Auth Flow', () => { - let app: INestApplication; - let accessToken: string; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - await app.init(); - - // Create and login test user - const uniqueEmail = `oidc-integration-${Date.now()}@example.com`; - await request(app.getHttpServer()).post('/auth/register').send({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'OIDC Integration User', - }); - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - if (loginResponse.body.accessToken) { - accessToken = loginResponse.body.accessToken; - } - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Token Validation via /auth/validate', () => { - it('should validate access token via auth endpoint', async () => { - if (!accessToken) { - console.log('Skipping - no access token available'); - return; - } - - const response = await request(app.getHttpServer()) - .post('/auth/validate') - .send({ token: accessToken }) - .expect((res) => { - expect([200, 401]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('valid', true); - expect(response.body).toHaveProperty('payload'); - } - }); - - it('should reject invalid token', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/validate') - .send({ token: 'invalid.jwt.token' }) - .expect((res) => { - expect([200, 401]).toContain(res.status); - }); - - if (response.status === 200) { - expect(response.body).toHaveProperty('valid', false); - } - }); - }); - - describe('JWKS for Token Verification', () => { - it('should provide consistent JWKS across endpoints', async () => { - const [jwks1, jwks2, jwks3] = await Promise.all([ - request(app.getHttpServer()).get('/api/auth/jwks'), - request(app.getHttpServer()).get('/api/oidc/jwks'), - request(app.getHttpServer()).get('/auth/jwks'), - ]); - - // All endpoints should return same or similar JWKS - if (jwks1.status === 200 && jwks2.status === 200) { - // Keys should be equivalent - expect(jwks1.body.keys?.length).toBe(jwks2.body.keys?.length); - } - }); - }); -}); diff --git a/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts b/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts deleted file mode 100644 index cda3f3a42..000000000 --- a/services/mana-core-auth/test/e2e/passkey-2fa.e2e-spec.ts +++ /dev/null @@ -1,578 +0,0 @@ -/** - * Passkey & 2FA E2E Tests - * - * Tests the HTTP layer for: - * 1. Passkey registration flow (auth required) - * 2. Passkey authentication flow (public) - * 3. Passkey management (list, rename, delete) - * 4. Auth guard enforcement on passkey endpoints - * 5. 2FA redirect detection during sign-in - * 6. Session-to-token exchange after 2FA verification - * - * WebAuthn crypto is handled by @simplewebauthn/server which is mocked - * at the module level (via jest-e2e.json moduleNameMapper). These tests - * focus on request/response shapes, status codes, and auth guard behavior. - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request from 'supertest'; -import { AppModule } from '../../src/app.module'; - -describe('Passkey & 2FA (E2E)', () => { - let app: INestApplication; - let accessToken: string; - let refreshToken: string; - let userId: string; - - const testEmail = `passkey-e2e-${Date.now()}@example.com`; - const testPassword = 'SecurePassword123!'; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ transform: true })); - await app.init(); - - // Register and login a test user for authenticated passkey operations - const registerResponse = await request(app.getHttpServer()).post('/auth/register').send({ - email: testEmail, - password: testPassword, - name: 'Passkey E2E User', - }); - - userId = registerResponse.body.id; - - const loginResponse = await request(app.getHttpServer()).post('/auth/login').send({ - email: testEmail, - password: testPassword, - }); - - accessToken = loginResponse.body.accessToken; - refreshToken = loginResponse.body.refreshToken; - }); - - afterAll(async () => { - await app.close(); - }); - - // ========================================================================= - // Passkey Registration Flow - // ========================================================================= - - describe('Passkey Registration Flow', () => { - it('should generate registration options for authenticated user', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', `Bearer ${accessToken}`) - .expect((res) => { - expect([200, 201]).toContain(res.status); - }); - - expect(response.body).toHaveProperty('options'); - expect(response.body).toHaveProperty('challengeId'); - expect(response.body.options).toHaveProperty('challenge'); - expect(typeof response.body.options.challenge).toBe('string'); - expect(response.body.options.challenge.length).toBeGreaterThan(0); - expect(typeof response.body.challengeId).toBe('string'); - }); - - it('should include RP info in registration options', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', `Bearer ${accessToken}`) - .expect((res) => { - expect([200, 201]).toContain(res.status); - }); - - const { options } = response.body; - expect(options).toHaveProperty('rp'); - expect(options.rp).toHaveProperty('name'); - expect(options.rp).toHaveProperty('id'); - }); - - it('should include user info in registration options', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', `Bearer ${accessToken}`) - .expect((res) => { - expect([200, 201]).toContain(res.status); - }); - - const { options } = response.body; - expect(options).toHaveProperty('user'); - expect(options.user).toHaveProperty('name'); - expect(options.user).toHaveProperty('displayName'); - }); - - it('should reject registration verify with invalid challenge', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/verify') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - challengeId: 'invalid-challenge-id', - credential: { - id: 'fake-credential-id', - rawId: 'fake-raw-id', - response: { - clientDataJSON: 'fake-client-data', - attestationObject: 'fake-attestation', - }, - type: 'public-key', - }, - }) - .expect(400); - - expect(response.body).toHaveProperty('message'); - expect(response.body.message).toMatch(/invalid|expired/i); - }); - - it('should reject registration verify with expired challenge', async () => { - // Get valid options but use a bogus challengeId - await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', `Bearer ${accessToken}`); - - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/verify') - .set('Authorization', `Bearer ${accessToken}`) - .send({ - challengeId: 'non-existent-challenge-id', - credential: { - id: 'fake-credential', - rawId: 'fake-raw', - response: { - clientDataJSON: 'fake', - attestationObject: 'fake', - }, - type: 'public-key', - }, - }) - .expect(400); - - expect(response.body.message).toMatch(/invalid|expired/i); - }); - }); - - // ========================================================================= - // Passkey Authentication Flow (Public Endpoints) - // ========================================================================= - - describe('Passkey Authentication Flow', () => { - it('should generate authentication options without auth', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/options') - .expect(200); - - expect(response.body).toHaveProperty('options'); - expect(response.body).toHaveProperty('challengeId'); - expect(response.body.options).toHaveProperty('challenge'); - expect(typeof response.body.options.challenge).toBe('string'); - expect(response.body.options.challenge.length).toBeGreaterThan(0); - expect(typeof response.body.challengeId).toBe('string'); - }); - - it('should include rpId in authentication options', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/options') - .expect(200); - - expect(response.body.options).toHaveProperty('rpId'); - }); - - it('should reject authentication verify with invalid challenge', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/verify') - .send({ - challengeId: 'invalid-challenge-id', - credential: { - id: 'fake-credential-id', - rawId: 'fake-raw-id', - response: { - clientDataJSON: 'fake-client-data', - authenticatorData: 'fake-auth-data', - signature: 'fake-signature', - }, - type: 'public-key', - }, - }) - .expect(400); - - expect(response.body).toHaveProperty('message'); - expect(response.body.message).toMatch(/invalid|expired/i); - }); - - it('should reject authentication verify without challengeId', async () => { - await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/verify') - .send({ - credential: { - id: 'fake-credential', - response: {}, - type: 'public-key', - }, - }) - .expect(400); - }); - }); - - // ========================================================================= - // Passkey Management (List, Rename, Delete) - // ========================================================================= - - describe('Passkey Management', () => { - it('should list passkeys for authenticated user (initially empty)', async () => { - const response = await request(app.getHttpServer()) - .get('/auth/passkeys') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - // New user should have no passkeys initially - expect(response.body.length).toBe(0); - }); - - it('should return 404 when deleting non-existent passkey', async () => { - await request(app.getHttpServer()) - .delete('/auth/passkeys/non-existent-id') - .set('Authorization', `Bearer ${accessToken}`) - .expect(404); - }); - - it('should return 404 when renaming non-existent passkey', async () => { - await request(app.getHttpServer()) - .patch('/auth/passkeys/non-existent-id') - .set('Authorization', `Bearer ${accessToken}`) - .send({ friendlyName: 'My Key' }) - .expect(404); - }); - }); - - // ========================================================================= - // Auth Guard Enforcement - // ========================================================================= - - describe('Auth Guard Enforcement', () => { - describe('Protected endpoints require JWT', () => { - it('POST /auth/passkeys/register/options requires auth', async () => { - await request(app.getHttpServer()).post('/auth/passkeys/register/options').expect(401); - }); - - it('POST /auth/passkeys/register/verify requires auth', async () => { - await request(app.getHttpServer()) - .post('/auth/passkeys/register/verify') - .send({ - challengeId: 'test', - credential: { id: 'test', response: {} }, - }) - .expect(401); - }); - - it('GET /auth/passkeys requires auth', async () => { - await request(app.getHttpServer()).get('/auth/passkeys').expect(401); - }); - - it('DELETE /auth/passkeys/:id requires auth', async () => { - await request(app.getHttpServer()).delete('/auth/passkeys/some-id').expect(401); - }); - - it('PATCH /auth/passkeys/:id requires auth', async () => { - await request(app.getHttpServer()) - .patch('/auth/passkeys/some-id') - .send({ friendlyName: 'test' }) - .expect(401); - }); - }); - - describe('Public endpoints do not require JWT', () => { - it('POST /auth/passkeys/authenticate/options is public', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/options') - .expect(200); - - expect(response.body).toHaveProperty('options'); - }); - - it('POST /auth/passkeys/authenticate/verify is public (fails on invalid data, not auth)', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/verify') - .send({ - challengeId: 'invalid', - credential: { id: 'test', response: {} }, - }); - - // Should get 400 (bad request) not 401 (unauthorized) - expect(response.status).toBe(400); - }); - }); - - describe('Invalid token handling', () => { - it('should reject passkey endpoints with invalid token', async () => { - await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', 'Bearer invalid-jwt-token') - .expect(401); - }); - - it('should reject passkey endpoints with malformed auth header', async () => { - await request(app.getHttpServer()) - .get('/auth/passkeys') - .set('Authorization', 'NotBearer token') - .expect(401); - }); - }); - }); - - // ========================================================================= - // 2FA Flow via Sign-In - // ========================================================================= - - describe('2FA Flow', () => { - it('should return standard login response when 2FA is not enabled', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: testEmail, - password: testPassword, - }) - .expect(200); - - // Normal user without 2FA should get tokens - expect(response.body).toHaveProperty('accessToken'); - expect(response.body).toHaveProperty('refreshToken'); - expect(response.body).not.toHaveProperty('twoFactorRedirect'); - }); - - it('session-to-token endpoint should exist', async () => { - // Without a valid session cookie, this should return 401 - const response = await request(app.getHttpServer()) - .post('/auth/session-to-token') - .expect((res) => { - // Should be 401 (no session cookie) not 404 (endpoint missing) - expect(res.status).not.toBe(404); - expect([200, 401]).toContain(res.status); - }); - - if (response.status === 401) { - expect(response.body).toHaveProperty('message'); - } - }); - - it('session-to-token should reject request without session cookie', async () => { - await request(app.getHttpServer()).post('/auth/session-to-token').expect(401); - }); - }); - - // ========================================================================= - // 2FA Passthrough Endpoints - // ========================================================================= - - describe('2FA Passthrough Routes', () => { - it('should expose two-factor enable endpoint (requires session)', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/enable') - .send({}); - - // Should not be 404 - the route exists even if auth fails - expect(response.status).not.toBe(404); - }); - - it('should expose two-factor verify-totp endpoint', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/verify-totp') - .send({ code: '000000' }); - - // Should not be 404 - the route exists - expect(response.status).not.toBe(404); - }); - - it('should expose two-factor disable endpoint', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/disable') - .send({}); - - expect(response.status).not.toBe(404); - }); - - it('should expose two-factor get-totp-uri endpoint', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/get-totp-uri') - .send({}); - - expect(response.status).not.toBe(404); - }); - - it('should expose two-factor generate-backup-codes endpoint', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/generate-backup-codes') - .send({}); - - expect(response.status).not.toBe(404); - }); - - it('should expose two-factor verify-backup-code endpoint', async () => { - const response = await request(app.getHttpServer()) - .post('/api/auth/two-factor/verify-backup-code') - .send({ code: 'fake-backup-code' }); - - expect(response.status).not.toBe(404); - }); - }); - - // ========================================================================= - // Passkey + Login Token Shape Consistency - // ========================================================================= - - describe('Token Response Shape Consistency', () => { - it('login and passkey-auth-verify should share the same token response shape', async () => { - // Login response shape - const loginResponse = await request(app.getHttpServer()) - .post('/auth/login') - .send({ - email: testEmail, - password: testPassword, - }) - .expect(200); - - // Verify the login token shape (passkey auth verify returns the same shape) - const tokenKeys = Object.keys(loginResponse.body); - expect(tokenKeys).toContain('user'); - expect(tokenKeys).toContain('accessToken'); - expect(tokenKeys).toContain('refreshToken'); - expect(tokenKeys).toContain('expiresIn'); - - expect(loginResponse.body.user).toHaveProperty('id'); - expect(loginResponse.body.user).toHaveProperty('email'); - expect(typeof loginResponse.body.accessToken).toBe('string'); - expect(typeof loginResponse.body.refreshToken).toBe('string'); - expect(typeof loginResponse.body.expiresIn).toBe('number'); - }); - }); - - // ========================================================================= - // Magic Link Flow - // ========================================================================= - - describe('Magic Link Flow', () => { - it('POST /api/auth/magic-link/send-magic-link should be routable', async () => { - const res = await request(app.getHttpServer()) - .post('/api/auth/magic-link/send-magic-link') - .send({ email: 'test@example.com' }); - // Should not be 404 (route exists) - expect(res.status).not.toBe(404); - }); - - it('GET /api/auth/magic-link/verify should be routable', async () => { - const res = await request(app.getHttpServer()) - .get('/api/auth/magic-link/verify') - .query({ token: 'invalid-token' }); - expect(res.status).not.toBe(404); - }); - }); - - // ========================================================================= - // Security Events / Audit Log - // ========================================================================= - - describe('Security Events / Audit Log', () => { - it('GET /auth/security-events requires authentication', async () => { - const res = await request(app.getHttpServer()).get('/auth/security-events'); - expect(res.status).toBe(401); - }); - - it('GET /auth/security-events returns events for authenticated user', async () => { - const res = await request(app.getHttpServer()) - .get('/auth/security-events') - .set('Authorization', `Bearer ${accessToken}`); - expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - }); - - it('GET /auth/security-events returns events with expected shape', async () => { - const res = await request(app.getHttpServer()) - .get('/auth/security-events') - .set('Authorization', `Bearer ${accessToken}`) - .expect(200); - - // User has logged in at least once, so there should be events - if (res.body.length > 0) { - const event = res.body[0]; - expect(event).toHaveProperty('id'); - expect(event).toHaveProperty('eventType'); - expect(event).toHaveProperty('createdAt'); - } - }); - }); - - // ========================================================================= - // Edge Cases - // ========================================================================= - - describe('Edge Cases', () => { - it('should handle empty body on register/options gracefully', async () => { - // The endpoint reads user from JWT, so empty body is fine - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/options') - .set('Authorization', `Bearer ${accessToken}`) - .send({}); - - expect([200, 201]).toContain(response.status); - expect(response.body).toHaveProperty('challengeId'); - }); - - it('should handle missing credential field on register/verify', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/register/verify') - .set('Authorization', `Bearer ${accessToken}`) - .send({ challengeId: 'some-challenge' }); - - expect([400, 500]).toContain(response.status); - }); - - it('should handle missing body on authenticate/verify', async () => { - const response = await request(app.getHttpServer()) - .post('/auth/passkeys/authenticate/verify') - .send({}); - - expect([400, 500]).toContain(response.status); - }); - - it('should not allow cross-user passkey deletion', async () => { - // Create a second user - const otherEmail = `passkey-other-${Date.now()}@example.com`; - await request(app.getHttpServer()).post('/auth/register').send({ - email: otherEmail, - password: testPassword, - name: 'Other User', - }); - - const otherLogin = await request(app.getHttpServer()).post('/auth/login').send({ - email: otherEmail, - password: testPassword, - }); - - const otherToken = otherLogin.body.accessToken; - - // Try to delete a non-existent passkey with other user's token - // This should return 404 (not found for this user) not 204 - await request(app.getHttpServer()) - .delete('/auth/passkeys/some-passkey-id') - .set('Authorization', `Bearer ${otherToken}`) - .expect(404); - }); - - it('should generate unique challenge IDs across requests', async () => { - const [res1, res2] = await Promise.all([ - request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(), - request(app.getHttpServer()).post('/auth/passkeys/authenticate/options').send(), - ]); - - expect(res1.body.challengeId).not.toBe(res2.body.challengeId); - expect(res1.body.options.challenge).not.toBe(res2.body.options.challenge); - }); - }); -}); diff --git a/services/mana-core-auth/test/integration/auth-flow.integration.spec.ts b/services/mana-core-auth/test/integration/auth-flow.integration.spec.ts deleted file mode 100644 index 373305862..000000000 --- a/services/mana-core-auth/test/integration/auth-flow.integration.spec.ts +++ /dev/null @@ -1,487 +0,0 @@ -/** - * Authentication Flow Integration Tests - * - * Tests complete authentication workflows: - * - Registration → Login → Token Generation - * - Token Refresh → Logout - * - Multi-device sessions - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { AuthService } from '../../src/auth/auth.service'; -import { CreditsService } from '../../src/credits/credits.service'; -import configuration from '../../src/config/configuration'; - -describe('Authentication Flow Integration Tests', () => { - let authService: AuthService; - let creditsService: CreditsService; - let module: TestingModule; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: [configuration], - isGlobal: true, - }), - ], - providers: [AuthService, CreditsService], - }).compile(); - - authService = module.get(AuthService); - creditsService = module.get(CreditsService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('B2C User Registration → Login → Token Flow', () => { - it('should complete full B2C registration and login flow', async () => { - const uniqueEmail = `test-b2c-${Date.now()}@example.com`; - - // Step 1: Register new user - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Test User', - }); - - expect(registerResult).toMatchObject({ - id: expect.any(String), - email: uniqueEmail, - name: 'Test User', - }); - - const userId = registerResult.id; - - // Step 2: Initialize credit balance - const balance = await creditsService.initializeUserBalance(userId); - - expect(balance).toMatchObject({ - userId, - balance: 0, - freeCreditsRemaining: 150, // Signup bonus - dailyFreeCredits: 5, - }); - - // Step 3: Login with credentials - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - expect(loginResult).toMatchObject({ - user: { - id: userId, - email: uniqueEmail, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - tokenType: 'Bearer', - expiresIn: 900, // 15 minutes - }); - - // Step 4: Validate access token - const validationResult = await authService.validateToken(loginResult.accessToken); - - expect(validationResult.valid).toBe(true); - expect(validationResult.payload).toMatchObject({ - sub: userId, - email: uniqueEmail, - role: 'user', - }); - }); - - it('should support multiple login sessions from different devices', async () => { - const uniqueEmail = `multi-device-${Date.now()}@example.com`; - - // Register user - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Multi Device User', - }); - - // Login from mobile device - const mobileLogin = await authService.login( - { - email: uniqueEmail, - password: 'SecurePassword123!', - deviceId: 'mobile-device-123', - deviceName: 'iPhone 15', - }, - '192.168.1.100', - 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' - ); - - // Login from web device - const webLogin = await authService.login( - { - email: uniqueEmail, - password: 'SecurePassword123!', - deviceId: 'web-device-456', - deviceName: 'Chrome Browser', - }, - '192.168.1.101', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - ); - - // Both sessions should be valid - expect(mobileLogin.accessToken).toBeDefined(); - expect(webLogin.accessToken).toBeDefined(); - expect(mobileLogin.accessToken).not.toBe(webLogin.accessToken); - - // Validate both tokens - const mobileValidation = await authService.validateToken(mobileLogin.accessToken); - const webValidation = await authService.validateToken(webLogin.accessToken); - - expect(mobileValidation.valid).toBe(true); - expect(webValidation.valid).toBe(true); - - // Session IDs should be different - expect(mobileValidation.payload.sessionId).not.toBe(webValidation.payload.sessionId); - }); - }); - - describe('Token Refresh Flow', () => { - it('should refresh tokens and rotate refresh token', async () => { - const uniqueEmail = `refresh-test-${Date.now()}@example.com`; - - // Register and login - await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Refresh Test User', - }); - - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - const originalRefreshToken = loginResult.refreshToken; - const originalAccessToken = loginResult.accessToken; - - // Wait a moment to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Refresh tokens - const refreshResult = await authService.refreshToken(originalRefreshToken); - - expect(refreshResult).toMatchObject({ - user: { - email: uniqueEmail, - }, - accessToken: expect.any(String), - refreshToken: expect.any(String), - }); - - // New tokens should be different - expect(refreshResult.accessToken).not.toBe(originalAccessToken); - expect(refreshResult.refreshToken).not.toBe(originalRefreshToken); - - // Old refresh token should be revoked - await expect(authService.refreshToken(originalRefreshToken)).rejects.toThrow( - 'Invalid refresh token' - ); - - // New refresh token should work - const secondRefreshResult = await authService.refreshToken(refreshResult.refreshToken); - expect(secondRefreshResult.accessToken).toBeDefined(); - }); - - it('should not allow refresh with revoked token after logout', async () => { - const uniqueEmail = `logout-test-${Date.now()}@example.com`; - - // Register and login - await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Logout Test User', - }); - - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - const refreshToken = loginResult.refreshToken; - - // Extract sessionId from access token - const validation = await authService.validateToken(loginResult.accessToken); - const sessionId = validation.payload.sessionId; - - // Logout - await authService.logout(sessionId); - - // Attempt to refresh with revoked token - await expect(authService.refreshToken(refreshToken)).rejects.toThrow('Invalid refresh token'); - }); - }); - - describe('Logout Flow', () => { - it('should revoke session on logout', async () => { - const uniqueEmail = `logout-flow-${Date.now()}@example.com`; - - // Register and login - await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Logout Flow User', - }); - - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - // Extract sessionId - const validation = await authService.validateToken(loginResult.accessToken); - const sessionId = validation.payload.sessionId; - - // Logout - const logoutResult = await authService.logout(sessionId); - - expect(logoutResult).toEqual({ - message: 'Logged out successfully', - }); - - // Refresh token should no longer work - await expect(authService.refreshToken(loginResult.refreshToken)).rejects.toThrow(); - }); - - it('should not affect other sessions when logging out one session', async () => { - const uniqueEmail = `multi-session-logout-${Date.now()}@example.com`; - - // Register - await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Multi Session User', - }); - - // Create two sessions - const session1 = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - deviceId: 'device-1', - }); - - const session2 = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - deviceId: 'device-2', - }); - - // Logout session 1 - const validation1 = await authService.validateToken(session1.accessToken); - await authService.logout(validation1.payload.sessionId); - - // Session 1 refresh token should not work - await expect(authService.refreshToken(session1.refreshToken)).rejects.toThrow(); - - // Session 2 should still work - const session2Refresh = await authService.refreshToken(session2.refreshToken); - expect(session2Refresh.accessToken).toBeDefined(); - }); - }); - - describe('Security Validations', () => { - it('should prevent registration with duplicate email', async () => { - const duplicateEmail = `duplicate-${Date.now()}@example.com`; - - // First registration - await authService.register({ - email: duplicateEmail, - password: 'SecurePassword123!', - name: 'First User', - }); - - // Second registration with same email should fail - await expect( - authService.register({ - email: duplicateEmail, - password: 'AnotherPassword456!', - name: 'Second User', - }) - ).rejects.toThrow('User with this email already exists'); - }); - - it('should reject login with incorrect password', async () => { - const uniqueEmail = `wrong-password-${Date.now()}@example.com`; - - await authService.register({ - email: uniqueEmail, - password: 'CorrectPassword123!', - name: 'Password Test User', - }); - - await expect( - authService.login({ - email: uniqueEmail, - password: 'WrongPassword123!', - }) - ).rejects.toThrow('Invalid credentials'); - }); - - it('should reject login for non-existent user', async () => { - await expect( - authService.login({ - email: `nonexistent-${Date.now()}@example.com`, - password: 'SomePassword123!', - }) - ).rejects.toThrow('Invalid credentials'); - }); - - it('should normalize email to lowercase', async () => { - const mixedCaseEmail = `MixedCase${Date.now()}@EXAMPLE.COM`; - - const registerResult = await authService.register({ - email: mixedCaseEmail, - password: 'SecurePassword123!', - name: 'Mixed Case User', - }); - - expect(registerResult.email).toBe(mixedCaseEmail.toLowerCase()); - - // Should be able to login with different casing - const loginResult = await authService.login({ - email: mixedCaseEmail.toUpperCase(), - password: 'SecurePassword123!', - }); - - expect(loginResult.user.email).toBe(mixedCaseEmail.toLowerCase()); - }); - }); - - describe('Credit Balance Integration', () => { - it('should initialize credit balance automatically on registration', async () => { - const uniqueEmail = `credits-init-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Credits User', - }); - - const userId = registerResult.id; - - // Initialize balance - const balance = await creditsService.initializeUserBalance(userId); - - expect(balance.freeCreditsRemaining).toBe(150); // Signup bonus - expect(balance.dailyFreeCredits).toBe(5); - expect(balance.balance).toBe(0); - }); - - it('should not create duplicate balances', async () => { - const uniqueEmail = `no-duplicate-balance-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'No Duplicate User', - }); - - const userId = registerResult.id; - - // Initialize balance twice - const balance1 = await creditsService.initializeUserBalance(userId); - const balance2 = await creditsService.initializeUserBalance(userId); - - // Should return the same balance - expect(balance1.userId).toBe(balance2.userId); - expect(balance1.freeCreditsRemaining).toBe(balance2.freeCreditsRemaining); - }); - }); - - describe('Error Handling', () => { - it('should handle soft-deleted user login attempt', async () => { - const uniqueEmail = `deleted-user-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'To Be Deleted', - }); - - // Note: In a real scenario, you'd soft-delete the user here - // For now, we just test the logic exists - - // This test validates the login check for deletedAt field exists - expect(registerResult.id).toBeDefined(); - }); - - it('should handle expired refresh token', async () => { - const uniqueEmail = `expired-token-${Date.now()}@example.com`; - - await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Expired Token User', - }); - - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - // Test with obviously invalid token - await expect(authService.refreshToken('invalid-refresh-token')).rejects.toThrow(); - }); - }); - - describe('Password Security', () => { - it('should hash passwords using bcrypt with proper cost factor', async () => { - const uniqueEmail = `password-hash-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'TestPassword123!', - name: 'Hash Test User', - }); - - // Login should work with correct password - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'TestPassword123!', - }); - - expect(loginResult.accessToken).toBeDefined(); - - // Login should fail with incorrect password - await expect( - authService.login({ - email: uniqueEmail, - password: 'WrongPassword123!', - }) - ).rejects.toThrow('Invalid credentials'); - }); - - it('should not expose password in any response', async () => { - const uniqueEmail = `no-password-leak-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'No Leak User', - }); - - // Registration response should not contain password - expect(registerResult).not.toHaveProperty('password'); - expect(registerResult).not.toHaveProperty('hashedPassword'); - - const loginResult = await authService.login({ - email: uniqueEmail, - password: 'SecurePassword123!', - }); - - // Login response should not contain password - expect(loginResult.user).not.toHaveProperty('password'); - expect(loginResult.user).not.toHaveProperty('hashedPassword'); - }); - }); -}); diff --git a/services/mana-core-auth/test/integration/credit-flow.integration.spec.ts b/services/mana-core-auth/test/integration/credit-flow.integration.spec.ts deleted file mode 100644 index c5afa38f3..000000000 --- a/services/mana-core-auth/test/integration/credit-flow.integration.spec.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Credit Flow Integration Tests - * - * Tests complete credit workflows: - * - B2C: Purchase → Use Credits → Balance Updates - * - B2B: Allocate → Deduct → Organization Tracking - * - Daily free credit reset - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; -import { CreditsService } from '../../src/credits/credits.service'; -import { AuthService } from '../../src/auth/auth.service'; -import configuration from '../../src/config/configuration'; - -describe('Credit Flow Integration Tests', () => { - let creditsService: CreditsService; - let authService: AuthService; - let module: TestingModule; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [ - ConfigModule.forRoot({ - load: [configuration], - isGlobal: true, - }), - ], - providers: [CreditsService, AuthService], - }).compile(); - - creditsService = module.get(CreditsService); - authService = module.get(AuthService); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('B2C Credit Flow', () => { - it('should complete full B2C credit lifecycle', async () => { - // Step 1: Register user - const uniqueEmail = `b2c-credits-${Date.now()}@example.com`; - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'B2C User', - }); - - const userId = registerResult.id; - - // Step 2: Initialize balance - const initialBalance = await creditsService.initializeUserBalance(userId); - - expect(initialBalance).toMatchObject({ - userId, - balance: 0, - freeCreditsRemaining: 150, // Signup bonus - dailyFreeCredits: 5, - }); - - // Step 3: Use free credits - const useCreditsResult = await creditsService.useCredits(userId, { - amount: 50, - appId: 'memoro', - description: 'Audio transcription', - metadata: { fileId: 'audio-123' }, - }); - - expect(useCreditsResult.success).toBe(true); - expect(useCreditsResult.newBalance).toMatchObject({ - balance: 0, // Paid credits unchanged - freeCreditsRemaining: 100, // 150 - 50 - totalSpent: 50, - }); - - // Step 4: Get updated balance - const updatedBalance = await creditsService.getBalance(userId); - - expect(updatedBalance).toMatchObject({ - balance: 0, - freeCreditsRemaining: 100, - totalSpent: 50, - }); - - // Step 5: Get transaction history - const transactions = await creditsService.getTransactionHistory(userId); - - expect(transactions.length).toBeGreaterThan(0); - expect(transactions[0]).toMatchObject({ - userId, - type: 'usage', - amount: -50, - appId: 'memoro', - }); - }); - - it('should prioritize free credits over paid credits', async () => { - const uniqueEmail = `credit-priority-${Date.now()}@example.com`; - - // Register and initialize - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Priority Test User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Note: In a real scenario, you'd add paid credits via purchase - // For this test, we assume user has both free and paid credits - - // Use credits - should use free first - const result = await creditsService.useCredits(userId, { - amount: 20, - appId: 'picture', - description: 'Image generation', - }); - - expect(result.success).toBe(true); - - // Free credits should be reduced - const balance = await creditsService.getBalance(userId); - expect(balance.freeCreditsRemaining).toBe(130); // 150 - 20 - }); - - it('should enforce idempotency for credit usage', async () => { - const uniqueEmail = `idempotency-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Idempotency User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - const idempotencyKey = `idempotent-key-${Date.now()}`; - - // First request - const result1 = await creditsService.useCredits(userId, { - amount: 10, - appId: 'chat', - description: 'Chat message', - idempotencyKey, - }); - - expect(result1.success).toBe(true); - - const balanceAfterFirst = await creditsService.getBalance(userId); - - // Second request with same idempotency key - const result2 = await creditsService.useCredits(userId, { - amount: 10, - appId: 'chat', - description: 'Chat message', - idempotencyKey, - }); - - expect(result2.success).toBe(true); - expect(result2.message).toBe('Transaction already processed'); - - // Balance should be unchanged - const balanceAfterSecond = await creditsService.getBalance(userId); - - expect(balanceAfterSecond.freeCreditsRemaining).toBe(balanceAfterFirst.freeCreditsRemaining); - expect(balanceAfterSecond.totalSpent).toBe(balanceAfterFirst.totalSpent); - }); - - it('should prevent credit usage with insufficient balance', async () => { - const uniqueEmail = `insufficient-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Insufficient User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Try to use more credits than available - await expect( - creditsService.useCredits(userId, { - amount: 200, // More than 150 signup bonus - appId: 'wisekeep', - description: 'Video analysis', - }) - ).rejects.toThrow('Insufficient credits'); - }); - }); - - describe('Daily Free Credit Reset', () => { - it('should apply daily free credits on new day', async () => { - const uniqueEmail = `daily-reset-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Daily Reset User', - }); - - const userId = registerResult.id; - - // Initialize balance - await creditsService.initializeUserBalance(userId); - - // Note: Daily reset logic checks if lastDailyResetAt is a different day - // In a real test with database, you'd manipulate the timestamp - // For now, we verify the getBalance method includes the check - - const balance = await creditsService.getBalance(userId); - - expect(balance.dailyFreeCredits).toBe(5); - expect(balance.freeCreditsRemaining).toBeDefined(); - }); - - it('should not reset credits on same day', async () => { - const uniqueEmail = `same-day-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Same Day User', - }); - - const userId = registerResult.id; - - await creditsService.initializeUserBalance(userId); - - // Get balance twice on same day - const balance1 = await creditsService.getBalance(userId); - const balance2 = await creditsService.getBalance(userId); - - // Free credits should be the same - expect(balance1.freeCreditsRemaining).toBe(balance2.freeCreditsRemaining); - }); - }); - - describe('Transaction History', () => { - it('should record all credit transactions', async () => { - const uniqueEmail = `transaction-history-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Transaction User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Perform multiple transactions - await creditsService.useCredits(userId, { - amount: 10, - appId: 'chat', - description: 'Chat 1', - }); - - await creditsService.useCredits(userId, { - amount: 15, - appId: 'picture', - description: 'Image gen', - }); - - await creditsService.useCredits(userId, { - amount: 20, - appId: 'memoro', - description: 'Audio', - }); - - // Get transaction history - const transactions = await creditsService.getTransactionHistory(userId); - - // Should have at least 4 transactions: signup bonus + 3 usage - expect(transactions.length).toBeGreaterThanOrEqual(4); - - // Most recent should be the last usage - expect(transactions[0].description).toContain('Audio'); - expect(transactions[0].amount).toBe(-20); - }); - - it('should support pagination for transaction history', async () => { - const uniqueEmail = `pagination-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Pagination User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Create multiple transactions - for (let i = 0; i < 10; i++) { - await creditsService.useCredits(userId, { - amount: 1, - appId: 'test', - description: `Transaction ${i}`, - }); - } - - // Get first page - const page1 = await creditsService.getTransactionHistory(userId, 5, 0); - expect(page1.length).toBeLessThanOrEqual(5); - - // Get second page - const page2 = await creditsService.getTransactionHistory(userId, 5, 5); - expect(page2.length).toBeGreaterThan(0); - - // Pages should have different transactions - if (page1.length > 0 && page2.length > 0) { - expect(page1[0].id).not.toBe(page2[0].id); - } - }); - }); - - describe('Package Management', () => { - it('should list available credit packages', async () => { - const packages = await creditsService.getPackages(); - - // Verify packages are returned - expect(Array.isArray(packages)).toBe(true); - - // Each package should have required fields - packages.forEach((pkg) => { - expect(pkg).toHaveProperty('id'); - expect(pkg).toHaveProperty('name'); - expect(pkg).toHaveProperty('credits'); - expect(pkg).toHaveProperty('priceEuroCents'); - expect(pkg.active).toBe(true); - }); - }); - }); - - describe('Usage Analytics', () => { - it('should track usage statistics per app', async () => { - const uniqueEmail = `analytics-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Analytics User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Use credits for different apps - await creditsService.useCredits(userId, { - amount: 10, - appId: 'chat', - description: 'Chat usage', - metadata: { conversationId: 'conv-1' }, - }); - - await creditsService.useCredits(userId, { - amount: 15, - appId: 'memoro', - description: 'Audio processing', - metadata: { fileId: 'audio-1' }, - }); - - // Verify transactions have metadata - const transactions = await creditsService.getTransactionHistory(userId); - - const chatTransaction = transactions.find((t) => t.appId === 'chat'); - expect(chatTransaction?.metadata).toMatchObject({ - conversationId: 'conv-1', - }); - - const memoroTransaction = transactions.find((t) => t.appId === 'memoro'); - expect(memoroTransaction?.metadata).toMatchObject({ - fileId: 'audio-1', - }); - }); - }); - - describe('Concurrent Credit Usage (Optimistic Locking)', () => { - it('should handle concurrent credit deductions safely', async () => { - const uniqueEmail = `concurrent-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Concurrent User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - // Note: In a real concurrent scenario, these would happen simultaneously - // For integration test, we verify the optimistic locking mechanism exists - - const result1 = await creditsService.useCredits(userId, { - amount: 10, - appId: 'test', - description: 'Request 1', - }); - - const result2 = await creditsService.useCredits(userId, { - amount: 15, - appId: 'test', - description: 'Request 2', - }); - - expect(result1.success).toBe(true); - expect(result2.success).toBe(true); - - // Final balance should reflect both deductions - const finalBalance = await creditsService.getBalance(userId); - expect(finalBalance.totalSpent).toBe(25); // 10 + 15 - }); - }); - - describe('Error Recovery', () => { - it('should maintain balance consistency after failed transaction', async () => { - const uniqueEmail = `error-recovery-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Error Recovery User', - }); - - const userId = registerResult.id; - await creditsService.initializeUserBalance(userId); - - const initialBalance = await creditsService.getBalance(userId); - - // Attempt transaction that will fail (insufficient credits) - try { - await creditsService.useCredits(userId, { - amount: 1000, - appId: 'test', - description: 'Will fail', - }); - } catch (error) { - // Expected to fail - } - - // Balance should be unchanged - const balanceAfterError = await creditsService.getBalance(userId); - - expect(balanceAfterError.freeCreditsRemaining).toBe(initialBalance.freeCreditsRemaining); - expect(balanceAfterError.balance).toBe(initialBalance.balance); - expect(balanceAfterError.totalSpent).toBe(initialBalance.totalSpent); - }); - }); - - describe('Credit Balance Initialization', () => { - it('should not create duplicate balances for same user', async () => { - const uniqueEmail = `no-duplicate-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'No Duplicate User', - }); - - const userId = registerResult.id; - - // Initialize twice - const balance1 = await creditsService.initializeUserBalance(userId); - const balance2 = await creditsService.initializeUserBalance(userId); - - expect(balance1.userId).toBe(userId); - expect(balance2.userId).toBe(userId); - expect(balance1.freeCreditsRemaining).toBe(balance2.freeCreditsRemaining); - }); - - it('should create transaction record for signup bonus', async () => { - const uniqueEmail = `signup-bonus-tx-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Signup Bonus User', - }); - - const userId = registerResult.id; - - await creditsService.initializeUserBalance(userId); - - const transactions = await creditsService.getTransactionHistory(userId); - - // Should have signup bonus transaction - const bonusTransaction = transactions.find( - (t) => t.type === 'bonus' && t.description === 'Signup bonus' - ); - - expect(bonusTransaction).toBeDefined(); - expect(bonusTransaction?.amount).toBe(150); - expect(bonusTransaction?.appId).toBe('system'); - }); - }); - - describe('Purchase History', () => { - it('should retrieve user purchase history', async () => { - const uniqueEmail = `purchase-history-${Date.now()}@example.com`; - - const registerResult = await authService.register({ - email: uniqueEmail, - password: 'SecurePassword123!', - name: 'Purchase User', - }); - - const userId = registerResult.id; - - // Note: In a real scenario, you'd create purchases via payment flow - // This test verifies the method exists and returns an array - - const purchases = await creditsService.getPurchaseHistory(userId); - - expect(Array.isArray(purchases)).toBe(true); - }); - }); -}); diff --git a/services/mana-core-auth/test/jest-e2e.json b/services/mana-core-auth/test/jest-e2e.json deleted file mode 100644 index bbb54dc81..000000000 --- a/services/mana-core-auth/test/jest-e2e.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": [ - "ts-jest", - { - "tsconfig": { - "esModuleInterop": true, - "allowSyntheticDefaultImports": true - } - } - ] - }, - "transformIgnorePatterns": ["node_modules/(?!(nanoid|better-auth)/)"], - "moduleNameMapper": { - "^nanoid$": "/__mocks__/nanoid.ts", - "^better-auth$": "/__mocks__/better-auth.ts", - "^better-auth/plugins$": "/__mocks__/better-auth-plugins.ts", - "^better-auth/plugins/(.*)$": "/__mocks__/better-auth-plugins.ts", - "^better-auth/adapters/(.*)$": "/__mocks__/better-auth-adapters.ts", - "^jose$": "/__mocks__/jose.ts" - }, - "testTimeout": 30000, - "setupFilesAfterEnv": ["./setup-e2e.ts"] -} diff --git a/services/mana-core-auth/test/setup-e2e.ts b/services/mana-core-auth/test/setup-e2e.ts deleted file mode 100644 index d270bfe11..000000000 --- a/services/mana-core-auth/test/setup-e2e.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Global E2E test setup - */ - -// Use crypto for generating random IDs instead of nanoid to avoid ESM issues -const crypto = require('crypto'); - -// Increase timeout for E2E tests -jest.setTimeout(30000); - -/** - * Generate random ID using crypto - */ -const generateRandomId = (length = 10): string => { - return crypto - .randomBytes(Math.ceil(length / 2)) - .toString('hex') - .slice(0, length); -}; - -/** - * Global test utilities for E2E tests - */ -global.e2eTestUtils = { - /** - * Generate unique test email - */ - generateTestEmail: (): string => { - return `test-${generateRandomId(10)}@example.com`; - }, - - /** - * Generate test user data - */ - generateTestUser: () => ({ - email: `test-${generateRandomId(10)}@example.com`, - password: 'TestPassword123!', - name: 'Test User', - }), - - /** - * Wait for server to be ready - */ - waitForServer: async (url: string, maxAttempts = 30): Promise => { - for (let i = 0; i < maxAttempts; i++) { - try { - const response = await fetch(`${url}/health/live`); - if (response.ok) { - return; - } - } catch (error) { - // Server not ready yet - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new Error('Server did not become ready in time'); - }, - - /** - * Clean up test data - */ - cleanupTestData: async (testIds: string[]) => { - // Implement cleanup logic here - // This should connect to the test database and delete test data - }, -}; - -// Type augmentation for E2E test utils -declare global { - var e2eTestUtils: { - generateTestEmail: () => string; - generateTestUser: () => { email: string; password: string; name: string }; - waitForServer: (url: string, maxAttempts?: number) => Promise; - cleanupTestData: (testIds: string[]) => Promise; - }; -} - -export {}; diff --git a/services/mana-core-auth/test/setup.ts b/services/mana-core-auth/test/setup.ts deleted file mode 100644 index 7bfbce427..000000000 --- a/services/mana-core-auth/test/setup.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Global test setup for unit tests - */ - -// Increase timeout for slower machines -jest.setTimeout(10000); - -// Suppress console logs during tests (optional - remove if you want to see logs) -// global.console = { -// ...console, -// log: jest.fn(), -// debug: jest.fn(), -// info: jest.fn(), -// warn: jest.fn(), -// }; - -// Global test utilities -global.testUtils = { - /** - * Wait for a condition to be true - */ - waitFor: async (condition: () => boolean, timeout = 5000, interval = 100): Promise => { - const startTime = Date.now(); - while (!condition()) { - if (Date.now() - startTime > timeout) { - throw new Error('Timeout waiting for condition'); - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - }, - - /** - * Sleep for a specified duration - */ - sleep: (ms: number): Promise => { - return new Promise((resolve) => setTimeout(resolve, ms)); - }, - - /** - * Mock console methods and restore them - */ - mockConsole: () => { - const originalLog = console.log; - const originalError = console.error; - const originalWarn = console.warn; - - const logs: string[] = []; - const errors: string[] = []; - const warns: string[] = []; - - console.log = jest.fn((...args) => logs.push(args.join(' '))); - console.error = jest.fn((...args) => errors.push(args.join(' '))); - console.warn = jest.fn((...args) => warns.push(args.join(' '))); - - return { - logs, - errors, - warns, - restore: () => { - console.log = originalLog; - console.error = originalError; - console.warn = originalWarn; - }, - }; - }, -}; - -// Type augmentation for global test utils -declare global { - var testUtils: { - waitFor: (condition: () => boolean, timeout?: number, interval?: number) => Promise; - sleep: (ms: number) => Promise; - mockConsole: () => { - logs: string[]; - errors: string[]; - warns: string[]; - restore: () => void; - }; - }; -} - -export {}; diff --git a/services/mana-core-auth/tsconfig.json b/services/mana-core-auth/tsconfig.json deleted file mode 100644 index 9ccd6af67..000000000 --- a/services/mana-core-auth/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "../", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "paths": { - "@/*": ["mana-core-auth/src/*"], - "@manacore/*": ["packages/*/src"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test"] -}